-
-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathvps_setup.txt
More file actions
747 lines (540 loc) · 24.3 KB
/
vps_setup.txt
File metadata and controls
747 lines (540 loc) · 24.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# VPS Setup Guide - Ubuntu 24.04 LTS
# Last Updated: January 2026
This is my barebones VPS setup for hosting web applications.
It covers security hardening, web server setup, and FastAPI deployment.
================================================================================
PART 1: BASE VPS SECURITY SETUP
================================================================================
## Create Linode Server (or any VPS provider)
-> Create Linode
-> Latest version of Ubuntu (24.04 LTS)
-> Choose Region (I chose Dallas, TX "us-central")
-> Choose a Linode Plan (Shared CPU with 1GB RAM for $5/mo works for small sites)
-> Give it a label (e.g., fastapi-blog)
-> Use a Strong Root Password
-> Create Linode
This will give you a public IP address to login to the server.
--------------------------------------------------------------------------------
## Initial Login and System Update
-> Copy your Public IP from the dashboard
-> Login as root (first and last time!)
ssh root@YOUR_IP_ADDRESS
-> Update the system
apt update && apt upgrade -y
# You may be prompted about modified config files (like /etc/ssh/sshd_config).
# Since this is a fresh install, accept the maintainer's version.
--------------------------------------------------------------------------------
## Create Non-Root User
-> Create a new user
adduser coreyms
# Enter a strong password when prompted
-> Grant sudo privileges
usermod -aG sudo coreyms
-> Verify the user has sudo
groups coreyms
# Should show: coreyms : coreyms sudo
--------------------------------------------------------------------------------
## Set Up SSH Key Authentication (Do this BEFORE disabling password auth!)
-> On your LOCAL machine (not the server!), generate an Ed25519 key:
ssh-keygen -t ed25519 -C "YourName - DeviceName - Date"
# The -C comment gets embedded in the public key. When you view
# authorized_keys on the server, you'll see who/what is allowed to connect.
# Example:
ssh-keygen -t ed25519 -C "CoreyMS - MacBook Pro - 2025-01"
-> When prompted for file location, use a descriptive name:
/Users/yourname/.ssh/id_ed25519_fastapiblog_macbookpro
# Format: id_ed25519_<server>_<device>
# This way you know which server and which device at a glance
-> Enter a strong passphrase (protects the key if your machine is compromised)
-> Copy the public key to your server (still on local machine):
ssh-copy-id -i ~/.ssh/id_ed25519_fastapiblog_macbookpro coreyms@YOUR_IP_ADDRESS
# Enter the password for coreyms when prompted
-> Add the key to your SSH agent (so you don't have to specify -i every time):
ssh-add ~/.ssh/id_ed25519_fastapiblog_macbookpro
-> Test login with the new user (IMPORTANT: Do this before disabling password auth!)
ssh coreyms@YOUR_IP_ADDRESS
# Should log in without asking for password (may ask for key passphrase)
-> Verify SSH directory permissions on the server (ssh-copy-id usually sets these,
but SSH is strict - it won't work if permissions are too open):
chmod 700 ~/.ssh
chmod 600 ~/.ssh/*
-> If it works, you can now exit and continue as the new user
--------------------------------------------------------------------------------
## Harden SSH Configuration
-> Now logged in as your non-root user, edit the SSH config:
sudo nano /etc/ssh/sshd_config
-> Find and change these settings (uncomment if needed):
# Disable root login entirely
PermitRootLogin no
# Ensure public key authentication is enabled
PubkeyAuthentication yes
# Disable password authentication (keys only!)
PasswordAuthentication no
# Disable empty passwords
PermitEmptyPasswords no
# Disable challenge-response (another password method)
KbdInteractiveAuthentication no
-> Save and exit (Ctrl+X, Y, Enter)
-> Restart SSH service
sudo systemctl restart ssh
-> IMPORTANT: Before closing your current session, open a NEW terminal and test:
ssh coreyms@YOUR_IP_ADDRESS
# If this works, you're safe. If not, use your current session to fix it!
--------------------------------------------------------------------------------
## Set Timezone
-> Check current timezone
timedatectl status
-> List available timezones (optional)
timedatectl list-timezones
timedatectl list-timezones | grep America
-> Set your timezone (I use UTC for servers, but use your preference)
sudo timedatectl set-timezone UTC
# Or for a specific region:
# sudo timedatectl set-timezone America/Chicago
-> Verify
timedatectl status
--------------------------------------------------------------------------------
## Configure Hostname
-> Set hostname
sudo hostnamectl set-hostname fastapi-blog
-> Verify
hostname
-> Add to hosts file
sudo nano /etc/hosts
-> Add this line under 127.0.0.1 (use your server's public IP, not your local IP):
YOUR_SERVER_IP fastapi-blog
-> Save and exit
--------------------------------------------------------------------------------
## Set Up Firewall (UFW)
-> Install UFW (usually pre-installed on Ubuntu)
sudo apt install ufw -y
-> Set default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
-> Allow SSH (do this BEFORE enabling!)
sudo ufw allow ssh
-> Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
-> Enable the firewall
sudo ufw enable
# Type 'y' to confirm
-> Verify status
sudo ufw status verbose
--------------------------------------------------------------------------------
## Set Up Fail2Ban (Brute Force Protection)
-> Install Fail2Ban
sudo apt install fail2ban -y
-> Create local config files (never edit the .conf files directly)
sudo cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
-> Edit the jail.local config
sudo nano /etc/fail2ban/jail.local
-> Find the [DEFAULT] section and set banaction to use UFW:
[DEFAULT]
# Use UFW for banning instead of raw iptables (keeps everything in one place)
banaction = ufw
-> Optional: Add your IP to the ignore list (still in [DEFAULT] section):
ignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IP
-> Find the [sshd] section and ensure these values:
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
bantime = 1h
findtime = 10m
logpath = %(sshd_log)s
backend = %(sshd_backend)s
-> Save and exit
-> Start and enable Fail2Ban
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
sudo systemctl status fail2ban
-> Check banned IPs (after some time)
sudo fail2ban-client status sshd
--------------------------------------------------------------------------------
## Enable Automatic Security Updates
# Note: unattended-upgrades only applies SECURITY updates by default,
# not all package updates. You're not getting bleeding-edge software -
# just security fixes for known vulnerabilities. This is the official
# Ubuntu tool for automatic updates, pre-installed and enabled by default.
-> Install unattended-upgrades (usually pre-installed)
sudo apt install unattended-upgrades -y
-> Enable automatic updates
sudo dpkg-reconfigure -plow unattended-upgrades
# Select "Yes" when prompted
-> Verify configuration
cat /etc/apt/apt.conf.d/20auto-upgrades
# Should show:
# APT::Periodic::Update-Package-Lists "1";
# APT::Periodic::Unattended-Upgrade "1";
-> Optional: Configure automatic reboot for kernel updates
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
# Uncomment and modify these lines:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
# Note: Only enable auto-reboot if you're okay with brief downtime
# Many prefer to manually reboot during maintenance windows
-> Check when updates last ran
cat /var/log/unattended-upgrades/unattended-upgrades.log
================================================================================
PART 2: WEB SERVER SETUP (NGINX)
================================================================================
## Install NGINX
-> Update packages and install
sudo apt update
sudo apt install nginx -y
-> Start and enable NGINX
sudo systemctl start nginx
sudo systemctl enable nginx
sudo systemctl status nginx
-> Test by visiting your IP in a browser
http://YOUR_IP_ADDRESS
# Should see the NGINX welcome page
-> Note on /var/www/html ownership:
# By default, /var/www/html is owned by root. NGINX (running as www-data)
# only needs READ access to serve files, which it has by default.
#
# You'd only change ownership to www-data if your web app needs to WRITE
# files to this directory (common with PHP apps, file upload systems, etc.)
#
# For our FastAPI setup, we don't need this because:
# - Our app lives in /var/www/fastapi-blog (owned by our user)
# - User uploads go to S3, not local filesystem
#
# But if you ever need it, here's how:
# sudo chown -R www-data:www-data /var/www/html
# sudo chmod -R 755 /var/www/html
--------------------------------------------------------------------------------
## Point Your Domain to the Server
Before configuring nginx for your domain, you need to point your domain's
DNS to your server. In your domain registrar (e.g., Namecheap):
-> Go to Domain List → Manage → Advanced DNS
-> Delete any existing A records or CNAME records for @ and www
(Namecheap may have default "parking" records)
-> Add two A records:
Type: A Record Host: @ Value: YOUR_SERVER_IP TTL: Automatic
Type: A Record Host: www Value: YOUR_SERVER_IP TTL: Automatic
-> Save changes
-> DNS propagation can take up to 48 hours (usually 5-30 minutes)
-> Test propagation (+short gives just the IP without extra DNS details):
dig yourdomain.com +short
dig www.yourdomain.com +short
# Both should return your server IP
Note: We're using simple A records rather than Linode's nameservers.
This keeps DNS management at your registrar, which is simpler for a
single server. Linode's nameserver approach (ns1.linode.com, etc.) is
useful if you want to manage everything in one place.
--------------------------------------------------------------------------------
## Configure NGINX for Your Domain
-> Create a site configuration
sudo nano /etc/nginx/sites-available/yourdomain.com
-> Add this configuration (we'll update it for FastAPI later):
# Redirect www to non-www
server {
listen 80;
listen [::]:80;
server_name www.yourdomain.com;
return 301 $scheme://yourdomain.com$request_uri;
}
# Main server block
server {
listen 80;
listen [::]:80;
server_name yourdomain.com;
# Temporary: serve static files (will change for FastAPI)
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
-> Enable the site with a symlink
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
-> Remove default site (optional)
sudo rm /etc/nginx/sites-enabled/default
-> Test configuration
sudo nginx -t
-> Reload NGINX
sudo systemctl reload nginx
--------------------------------------------------------------------------------
## SSL/TLS Certificate (Let's Encrypt)
-> Install Certbot via Snap (recommended method)
sudo apt update
sudo apt install snapd -y
sudo snap install core
sudo snap refresh core
-> Remove any old certbot if present
sudo apt remove certbot -y
-> Install Certbot
sudo snap install --classic certbot
-> Create symlink for easier access
sudo ln -s /snap/bin/certbot /usr/bin/certbot
-> Confirm DNS has propagated before running Certbot (Certbot will fail if DNS isn't ready). If it isn't ready, wait.
dig yourdomain.com +short
# Should return your server's IP
-> Obtain certificate (replace with your domain)
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Follow the prompts, enter your email, agree to terms
-> Test automatic renewal
sudo certbot renew --dry-run
-> Verify renewal timer is active
systemctl list-timers | grep certbot
# Should show snap.certbot.renew.timer
-> Your site should now be accessible via HTTPS!
================================================================================
PART 3: FASTAPI APPLICATION SETUP
================================================================================
## Install Python and UV
-> Install Python and required packages
sudo apt update
sudo apt install python3 python3-pip python3-venv git -y
-> Verify Python version (should be 3.12+ on Ubuntu 24.04)
python3 --version
-> Install UV (fast Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# The installer automatically updates ~/.bashrc to add UV to your PATH.
# If for some reason it doesn't, add it manually:
# echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
-> Reload your shell to pick up the PATH changes
source ~/.bashrc
-> Verify UV installation
uv --version
--------------------------------------------------------------------------------
## Install and Configure PostgreSQL
-> Install PostgreSQL
sudo apt update
sudo apt install postgresql postgresql-contrib -y
-> Start and enable PostgreSQL
sudo systemctl start postgresql
sudo systemctl enable postgresql
sudo systemctl status postgresql
-> Create a database user and database for your app
sudo -u postgres psql
# In the PostgreSQL prompt:
CREATE USER bloguser WITH PASSWORD 'your_secure_password';
CREATE DATABASE blog OWNER bloguser;
GRANT ALL PRIVILEGES ON DATABASE blog TO bloguser;
\q
-> Test the connection
psql -U bloguser -d blog -h localhost
# Enter password when prompted
# Type \q to exit
-> Note your connection string for later:
postgresql+psycopg://bloguser:your_secure_password@localhost/blog
--------------------------------------------------------------------------------
## Deploy Your FastAPI Application
-> Create a directory for your application
sudo mkdir -p /var/www/fastapi-blog
sudo chown $USER:$USER /var/www/fastapi-blog
-> Clone your repository (or copy files)
cd /var/www/fastapi-blog
git clone https://github.com/yourusername/your-repo.git .
# Or if using a private repo, set up deploy keys first
# Alternative: Copy files from your local machine using scp
# Run this on your LOCAL machine (not the server):
# scp -r /path/to/your/project/* coreyms@YOUR_IP_ADDRESS:/var/www/fastapi-blog/
-> Set secure file permissions (deny by default, then open what's needed)
# Lock down everything first: owner read/write only for files,
# owner read/write/execute only for directories
find . -type f -exec chmod 600 {} \;
find . -type d -exec chmod 700 {} \;
# Open up what nginx (www-data) needs: it must traverse the project
# directory and read static files
chmod 755 .
chmod -R 755 static
-> Create virtual environment and install dependencies with UV
# (should already be in /var/www/fastapi-blog from previous step)
uv sync
-> Create your .env file
# Generate a secure secret key with:
python3 -c "import secrets; print(secrets.token_hex(32))"
# Copy the key
nano .env
# Add your environment variables:
DATABASE_URL=postgresql+psycopg://bloguser:your_secure_password@localhost/blog
SECRET_KEY=PASTE_THE_KEY
MAIL_SERVER=smtp.yourprovider.com
MAIL_PORT=587
MAIL_USERNAME=your_email
MAIL_PASSWORD=your_app_password
MAIL_FROM=noreply@yourdomain.com
MAIL_USE_TLS=true
FRONTEND_URL=https://yourdomain.com
S3_BUCKET_NAME=your-bucket
S3_REGION=us-east-1
# etc...
-> Secure the .env file
chmod 600 .env
-> Run database migrations
cd /var/www/fastapi-blog
uv run alembic upgrade head
-> Test the application manually
# Temporarily open port 8000 in the firewall for testing
sudo ufw allow 8000/tcp
uv run fastapi run --host 0.0.0.0 --port 8000
# Visit http://YOUR_IP:8000 to test
# Ctrl+C to stop
# Close the temporary port (nginx will handle traffic from here on)
sudo ufw delete allow 8000/tcp
--------------------------------------------------------------------------------
## Set Up FastAPI with systemd
# Note: The traditional "Gunicorn + Uvicorn workers" pattern still works,
# but uvicorn.workers.UvicornWorker is deprecated. Plain Uvicorn (or the
# FastAPI CLI) with --workers is simpler and recommended now. systemd
# handles process lifecycle (restart on crash, start on boot).
-> Create a systemd service file
sudo nano /etc/systemd/system/fastapi-blog.service
-> Add this configuration:
[Unit]
Description=FastAPI Blog Application
After=network.target postgresql.service
Wants=postgresql.service
[Service]
User=coreyms
Group=www-data
WorkingDirectory=/var/www/fastapi-blog
Environment="PATH=/var/www/fastapi-blog/.venv/bin"
EnvironmentFile=/var/www/fastapi-blog/.env
# Set --workers to your CPU core count. Check with: nproc
# For async workers, use 1 worker per core (not the old 2*cores+1 formula)
# --proxy-headers tells Uvicorn to trust X-Forwarded-* headers from nginx
ExecStart=/var/www/fastapi-blog/.venv/bin/fastapi run \
--workers 1 \
--host 127.0.0.1 \
--port 8000 \
--proxy-headers
Restart=always
RestartSec=5
# Note: Uvicorn only trusts forwarded headers from 127.0.0.1 by default.
# Since nginx runs on the same server (localhost), this works. If nginx
# were on a different server, you'd also need --forwarded-allow-ips="x.x.x.x"
[Install]
WantedBy=multi-user.target
-> Reload systemd and start the service
sudo systemctl daemon-reload
sudo systemctl start fastapi-blog
sudo systemctl enable fastapi-blog
-> Check status
sudo systemctl status fastapi-blog
-> View logs (systemd captures stdout/stderr automatically)
sudo journalctl -u fastapi-blog -f # Follow logs in real-time
sudo journalctl -u fastapi-blog --since "1 hour ago" # Recent logs
sudo journalctl -u fastapi-blog -n 100 # Last 100 lines
--------------------------------------------------------------------------------
## Configure NGINX as Reverse Proxy for FastAPI
-> Update your NGINX configuration
sudo nano /etc/nginx/sites-available/yourdomain.com
-> Replace the contents with:
# Redirect www to non-www
server {
listen 80;
listen [::]:80;
server_name www.yourdomain.com;
return 301 https://yourdomain.com$request_uri;
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
# Main HTTPS server block
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name yourdomain.com;
# SSL certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# HSTS: Tell browsers to always use HTTPS (max-age is 2 years)
# "preload" allows submission to browser preload lists (hstspreload.org)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Serve static files directly
location /static/ {
alias /var/www/fastapi-blog/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Proxy to FastAPI
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (if needed)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# File upload size limit (matches our app's max_upload_size_bytes setting)
client_max_body_size 5M;
}
-> Test configuration
sudo nginx -t
-> Reload NGINX
sudo systemctl reload nginx
-> Your FastAPI app should now be live at https://yourdomain.com!
--------------------------------------------------------------------------------
## Useful Commands for Maintenance
# Check application status
sudo systemctl status fastapi-blog
# Restart application (after code changes)
sudo systemctl restart fastapi-blog
# View application logs
sudo journalctl -u fastapi-blog -f
# View NGINX logs
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log
# Update application code
cd /var/www/fastapi-blog
git pull
uv sync
uv run alembic upgrade head
sudo systemctl restart fastapi-blog
# Check disk space
df -h
# Check memory usage
free -h
# Check running processes
htop
================================================================================
NOTES AND REMINDERS
================================================================================
## Security Checklist
[x] Non-root user with sudo
[x] SSH key authentication only (no passwords)
[x] Root login disabled
[x] Firewall enabled (UFW)
[x] Fail2Ban protecting SSH (integrated with UFW)
[x] Automatic security updates enabled
[x] File permissions locked down (deny by default)
[x] SSL/TLS certificate installed
[x] Security headers in NGINX (including HSTS with preload)
[x] Uvicorn configured with --proxy-headers for nginx
## File Storage
Our FastAPI app uses S3 for file uploads (profile pictures).
No server-side file storage configuration needed.
Static files (CSS, JS, default images) are served from /static/.
## Database Backups (TODO for production)
Consider setting up automated PostgreSQL backups:
- pg_dump with cron
- Or a managed backup service
## Monitoring (TODO for production)
Consider adding:
- Uptime monitoring (UptimeRobot, Pingdom)
- Error tracking (Sentry)
- Log aggregation (if needed)