Your app works on localhost. It runs perfectly on your machine. But the moment someone asks "can I see it?" you realize the gap between a working project and a deployed project is enormous. You need a server, a domain, SSL, a process manager, a firewall — and somehow all of these pieces need to work together without breaking.
This guide walks you through deploying a web application on a Virtual Private Server from scratch. We start with choosing the right hosting platform, move through server hardening and Nginx configuration, set up SSL with Let's Encrypt, create systemd services so your app survives reboots, build an automated deployment pipeline with GitHub webhooks, and finish with monitoring tools that cost nothing. Every command in this article is real and copy-pasteable. By the end, you will have a production server that is secure, automated, and maintainable.
1. VPS vs PaaS: When to Use What
Before you spin up a server, you need to decide whether a VPS is even the right choice. Platform-as-a-Service providers like Vercel, Railway, and Render abstract away all the server management. You push code, they deploy it. That sounds great — until you hit the limitations.
The Comparison
| Platform | Starting Price | Best For | Limitations |
|---|---|---|---|
| DigitalOcean | $6/mo (1 vCPU, 1GB RAM) | Full-stack apps, APIs, databases, anything custom | You manage everything yourself |
| Hetzner | $4.50/mo (2 vCPU, 2GB RAM) | Best price-to-performance ratio, EU data centers | Smaller community, EU-focused |
| Linode (Akamai) | $5/mo (1 vCPU, 1GB RAM) | Similar to DigitalOcean, good docs | You manage everything yourself |
| Vercel | Free tier / $20/mo Pro | Next.js, static sites, frontend deployments | No persistent servers, serverless only, 10s function timeout on free |
| Railway | $5/mo hobby plan | Quick prototypes, hackathon projects, demos | Expensive at scale ($0.000463/vCPU/min), limited free tier |
| Render | Free tier / $7/mo starter | Simple web services, auto-deploy from GitHub | Free tier sleeps after 15 min inactivity, cold starts of 30–60s |
The Decision Framework
Use Vercel if you are deploying a Next.js or static frontend. It is genuinely the best option for this. The free tier is generous, deploys are instant, and the edge network is fast. But Vercel is not a general-purpose server — you cannot run a Python backend, a WebSocket server, or a custom database on it.
Use Railway if you need to demo something in the next 30 minutes. Push your repo, Railway detects the language, deploys it. Done. But watch the bill. Railway charges per minute of compute. A simple Node.js app running 24/7 on their hobby plan costs around $5/mo, but a heavier app with a database can climb to $15–30/mo quickly. For a student project that needs to stay online, this adds up.
Use Render if you want auto-deploy from GitHub without managing a server. The free tier works for portfolio projects, but your app sleeps after 15 minutes of no traffic. The first visitor after sleep waits 30–60 seconds for a cold start. That is fine for a portfolio, but not for anything with real users.
Use a VPS for everything else. If you are running a backend API, a database, a WebSocket server, background workers, cron jobs, or anything that needs to be online 24/7 — a VPS is the cheapest and most flexible option. A $5–6/mo droplet on DigitalOcean gives you a full Linux server with root access. You can run multiple apps on a single server. You can install anything. And you learn real server management skills that directly transfer to professional DevOps work.
The real reason to learn VPS deployment: Every PaaS is an abstraction over what a VPS does. When Vercel deploys your Next.js app, behind the scenes it is running on servers configured with Nginx (or a similar reverse proxy), SSL certificates, process managers, and load balancers. Understanding the underlying layer makes you a better engineer and makes debugging production issues far easier. Companies pay $120K+ for DevOps engineers who understand this stack.
2. Server Setup from Zero
This section assumes you have just created a fresh Ubuntu 24.04 LTS droplet (or equivalent) on DigitalOcean, Hetzner, or Linode. You have the server's IP address and root access. We are going to secure it properly before deploying anything.
Step 1: Generate SSH Keys (On Your Local Machine)
If you do not already have SSH keys, generate them. Do this on your laptop, not on the server.
ssh-keygen -t ed25519 -C "your_email@example.com"
# Press Enter to accept the default file location (~/.ssh/id_ed25519)
# Enter a passphrase (recommended) or press Enter for none
# View your public key (you will need this)
cat ~/.ssh/id_ed25519.pub
When you create a server on DigitalOcean or Hetzner, you can paste this public key during setup. If you already have a server running, copy the key to it manually:
Step 2: First Login and System Updates
ssh root@YOUR_SERVER_IP
# Update all packages immediately
apt update && apt upgrade -y
# Install essential tools
apt install -y curl wget git ufw fail2ban unattended-upgrades
Step 3: Create a Non-Root User
Running everything as root is the single most common mistake new server administrators make. If your application has a vulnerability and an attacker exploits it, they get root access — full control of your entire server. A non-root user with sudo limits the blast radius.
adduser deploy
# Give the user sudo privileges
usermod -aG sudo deploy
# Copy your SSH keys to the new user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
# Test: open a NEW terminal and SSH in as the new user
ssh deploy@YOUR_SERVER_IP
# Verify sudo works
sudo whoami # Should output: root
Step 4: Disable Root Login and Password Authentication
Once you confirm you can SSH in as your new user with sudo, lock down the SSH configuration.
# Find and change these lines:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
# Save and restart SSH
sudo systemctl restart sshd
Do not lock yourself out. Before restarting SSH, keep your current terminal open and test in a second terminal that you can SSH in as the new user. If you disable password auth and your key is not set up correctly, you lose access to your server permanently.
Step 5: Configure the Firewall (UFW)
UFW (Uncomplicated Firewall) blocks all incoming traffic except the ports you explicitly allow. This is essential. Without a firewall, every port on your server is exposed to the internet.
sudo ufw allow 22/tcp
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
# Verify the rules
sudo ufw status verbose
The output should show three rules: 22/tcp ALLOW, 80/tcp ALLOW, and 443/tcp ALLOW. Everything else is denied by default. Your application's port (e.g., 3000 or 8000) does not need to be opened because Nginx will proxy traffic from port 80/443 to your app internally.
Step 6: Install and Configure fail2ban
fail2ban monitors your log files and automatically bans IP addresses that show malicious behavior — like repeatedly failing SSH login attempts. Without it, bots will try thousands of password combinations against your server every day.
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
# Find the [sshd] section and ensure it says:
[sshd]
enabled = true
port = 22
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
# Start and enable fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Check status
sudo fail2ban-client status sshd
This configuration bans any IP that fails SSH login 3 times within 10 minutes (findtime = 600 seconds). The ban lasts 1 hour (bantime = 3600 seconds). In production, you might increase bantime to 86400 (24 hours) or even -1 (permanent).
Step 7: Enable Automatic Security Updates
# Select "Yes" when prompted
# This ensures critical security patches are applied automatically
At this point your server is hardened: SSH key-only authentication, no root login, firewall blocking everything except web traffic and SSH, fail2ban protecting against brute force, and automatic security updates. This takes 10 minutes and prevents the vast majority of common attacks.
3. Nginx + SSL
Your application runs on a local port like 3000 or 8000. Users on the internet connect to port 80 (HTTP) or 443 (HTTPS). Nginx sits between them, receiving requests on ports 80/443 and forwarding them to your application. This is called a reverse proxy.
Install Nginx
sudo systemctl enable nginx
sudo systemctl start nginx
Visit your server's IP address in a browser. You should see the Nginx welcome page. If you do not, check that UFW allows port 80 (sudo ufw status).
Configure the Reverse Proxy
Create a new configuration file for your application. This example assumes your app runs on port 8000 (common for Python/Django/FastAPI) but works the same for port 3000 (Node.js/Express) — just change the port number.
# Paste this configuration:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json
application/javascript text/xml application/xml
application/xml+rss text/javascript;
gzip_min_length 1000;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 86400;
}
}
Let us break down what each part does:
- listen 80 — Accept HTTP traffic on port 80.
- server_name — Only handle requests for your domain. Replace
yourdomain.comwith your actual domain. - Security headers — Prevent clickjacking (X-Frame-Options), MIME-type sniffing (X-Content-Type-Options), and cross-site scripting (X-XSS-Protection). These are free and take one line each.
- Gzip compression — Compresses text-based responses before sending them to the browser. Reduces bandwidth by 60–80% for HTML, CSS, and JavaScript.
- proxy_pass — Forward all requests to your app running on localhost:8000.
- Upgrade and Connection headers — Enable WebSocket support. Without these, real-time features like chat or live updates will not work.
- X-Real-IP and X-Forwarded-For — Pass the visitor's real IP address to your application. Without these, your app sees all traffic coming from 127.0.0.1.
- proxy_read_timeout 86400 — Allow long-lived WebSocket connections (24 hours). The default 60 seconds would kill persistent connections.
Enable the site and test the configuration:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
# Remove the default site (optional but recommended)
sudo rm /etc/nginx/sites-enabled/default
# Test the configuration for syntax errors
sudo nginx -t
# If the test passes, reload Nginx
sudo systemctl reload nginx
Set Up SSL with Let's Encrypt
Before running Certbot, make sure your domain's DNS A record points to your server's IP address. DNS propagation can take up to 48 hours, but usually completes in minutes. You can check with dig yourdomain.com or a tool like dnschecker.org.
sudo apt install -y certbot python3-certbot-nginx
# Obtain and install the certificate
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Certbot will:
# 1. Verify you own the domain
# 2. Generate the SSL certificate
# 3. Automatically modify your Nginx config to use HTTPS
# 4. Set up HTTP-to-HTTPS redirect
# When prompted, choose "Redirect" (option 2) to automatically
# redirect all HTTP traffic to HTTPS
Certbot modifies your Nginx config automatically. After running it, your config will have additional lines for SSL certificate paths, HTTPS listening on port 443, and a redirect block that sends all HTTP traffic to HTTPS.
Auto-Renewal
Let's Encrypt certificates expire every 90 days. Certbot installs a systemd timer that automatically renews them. Verify it is active:
sudo systemctl status certbot.timer
# Test renewal (dry run, does not actually renew)
sudo certbot renew --dry-run
# If the dry run succeeds, auto-renewal is working
If you want a cron job as a backup (belt and suspenders), add one:
sudo crontab -e
# Add this line (runs twice daily at random minutes)
0 3,15 * * * certbot renew --quiet --post-hook "systemctl reload nginx"
At this point, your server accepts HTTPS traffic, compresses responses, forwards requests to your application, and automatically renews SSL certificates. The next step is making sure your application actually stays running.
4. Running Your App as a Service
If you SSH into your server and run python app.py or node server.js, your app starts. But the moment you close your terminal or your SSH connection drops, the process dies. You need a process manager that starts your app on boot, restarts it if it crashes, and keeps it running in the background.
Option A: systemd (Recommended for All Languages)
systemd is the init system built into every modern Linux distribution. It manages services — programs that run in the background. You create a "unit file" that tells systemd how to start your app, and systemd handles the rest.
Here is a systemd service file for a Python application (FastAPI/Django/Flask):
[Unit]
Description=My Web Application
After=network.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/myapp
Environment="PATH=/home/deploy/myapp/venv/bin"
ExecStart=/home/deploy/myapp/venv/bin/gunicorn \
--workers 3 \
--bind 127.0.0.1:8000 \
app:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
And here is one for a Node.js application:
[Unit]
Description=My Node.js Application
After=network.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/myapp
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
Let us break down what each directive means:
- After=network.target — Wait until the network is available before starting. Your app probably needs network access to connect to databases or external APIs.
- User=deploy — Run the app as your non-root user. Never run applications as root.
- WorkingDirectory — The directory where your app's code lives. Relative file paths in your code will resolve from here.
- ExecStart — The exact command to start your app. Use absolute paths to avoid ambiguity.
- Restart=always — If the app crashes, systemd restarts it automatically. This is the single most important line. Without it, a crash at 3 AM means your app stays down until you wake up.
- RestartSec=3 — Wait 3 seconds before restarting after a crash. Prevents rapid restart loops if there is a persistent error.
- WantedBy=multi-user.target — Start this service when the server boots into normal multi-user mode (which is always, unless something is very wrong).
Enable and start the service:
sudo systemctl daemon-reload
# Enable the service (start on boot)
sudo systemctl enable myapp
# Start the service now
sudo systemctl start myapp
# Check the status
sudo systemctl status myapp
# View live logs
sudo journalctl -u myapp -f
The journalctl -u myapp -f command follows the log output in real time, similar to tail -f. Press Ctrl+C to stop following. To see the last 100 lines of logs, use journalctl -u myapp -n 100.
Option B: PM2 (Node.js Specific)
If you are running a Node.js application and prefer a dedicated tool, PM2 is an excellent process manager. It provides the same functionality as systemd (auto-restart, boot startup) plus some Node.js-specific features like cluster mode and a built-in monitoring dashboard.
sudo npm install -g pm2
# Start your app
pm2 start server.js --name myapp
# Enable cluster mode (uses all CPU cores)
pm2 start server.js --name myapp -i max
# Save the process list so it survives reboot
pm2 save
# Generate a startup script (run the command PM2 gives you)
pm2 startup systemd
# Useful PM2 commands
pm2 status # List all running apps
pm2 logs myapp # View logs
pm2 restart myapp # Restart the app
pm2 monit # Real-time monitoring dashboard
systemd vs PM2: For Python, Go, Rust, Java, or any non-Node.js application, use systemd. It is built into the OS and has zero dependencies. For Node.js, both work well. PM2 adds cluster mode (spreading your app across CPU cores) and a nicer monitoring UI. systemd is simpler and does not require installing anything. Our recommendation: use systemd for everything and learn it well. It is a transferable skill that works with any language on any Linux server.
Loading Environment Variables
Your app needs environment variables for database URLs, API keys, and secrets. Never hardcode these. With systemd, you have two options:
Environment=DATABASE_URL=postgresql://user:pass@localhost/mydb
Environment=SECRET_KEY=your-secret-key-here
# Option 2: Use an environment file (for many variables)
# In the [Service] section, add:
EnvironmentFile=/home/deploy/myapp/.env
# The .env file format (no quotes, no export keyword):
DATABASE_URL=postgresql://user:pass@localhost/mydb
SECRET_KEY=your-secret-key-here
REDIS_URL=redis://localhost:6379
Make sure the .env file has restrictive permissions:
chown deploy:deploy /home/deploy/myapp/.env
This ensures only the deploy user can read the file. No other user on the system can see your secrets.
5. Automated Deployment
At this point you have a working server: Nginx proxies traffic to your app, SSL encrypts everything, and systemd keeps your app running. But every time you push new code, you have to SSH in, pull the changes, install dependencies, and restart the service. That gets old fast. Let us automate it.
The Manual Deploy (Baseline)
Before automating, know the manual process. Every automated deploy is just scripting these steps:
ssh deploy@YOUR_SERVER_IP
# Navigate to your app directory
cd /home/deploy/myapp
# Pull the latest code
git pull origin main
# Install/update dependencies
# For Python:
source venv/bin/activate
pip install -r requirements.txt
# For Node.js:
npm ci --production
# Run database migrations (if applicable)
python manage.py migrate # Django
npx prisma migrate deploy # Prisma
# Restart the service
sudo systemctl restart myapp
The Deploy Script
Wrap the manual steps in a script that lives on your server:
#!/bin/bash
set -e # Exit immediately if any command fails
APP_DIR="/home/deploy/myapp"
BRANCH="main"
LOG_FILE="/home/deploy/deploy.log"
echo "$(date): Starting deployment..." | tee -a $LOG_FILE
cd $APP_DIR
# Pull latest code
git fetch origin
git reset --hard origin/$BRANCH
# Install dependencies (uncomment the one you need)
# Python:
# source venv/bin/activate && pip install -r requirements.txt
# Node.js:
# npm ci --production
# Build step (if needed)
# npm run build
# Run migrations (if needed)
# python manage.py migrate
# Restart the application
sudo systemctl restart myapp
echo "$(date): Deployment complete!" | tee -a $LOG_FILE
chmod +x /home/deploy/deploy.sh
# Allow the deploy user to restart the service without a password
sudo visudo
# Add this line at the bottom:
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp
Now you can deploy with a single command from your local machine:
Auto-Deploy with GitHub Webhooks
The goal: push to the main branch on GitHub, and your server automatically deploys the new code. No SSH required. This is how professional teams work.
We will use a tiny webhook listener that GitHub pings whenever you push code. The listener runs the deploy script.
sudo apt install -y webhook
# Create the webhook configuration
mkdir -p /home/deploy/webhooks
nano /home/deploy/webhooks/hooks.json
{
"id": "deploy",
"execute-command": "/home/deploy/deploy.sh",
"command-working-directory": "/home/deploy/myapp",
"response-message": "Deploying...",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hmac-sha256",
"secret": "your-webhook-secret-here",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
}
]
}
}
]
The trigger-rule with HMAC-SHA256 verification ensures that only GitHub (which knows the secret) can trigger deployments. Without this, anyone who discovers your webhook URL could deploy arbitrary code to your server.
Create a systemd service for the webhook listener:
[Unit]
Description=GitHub Webhook Listener
After=network.target
[Service]
User=deploy
ExecStart=/usr/bin/webhook -hooks /home/deploy/webhooks/hooks.json \
-port 9000 -verbose
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable webhook
sudo systemctl start webhook
Add a location block to your Nginx config to proxy webhook traffic:
location /hooks/ {
proxy_pass http://127.0.0.1:9000/hooks/;
}
Finally, configure the webhook on GitHub:
- Go to your repository on GitHub → Settings → Webhooks → Add webhook
- Payload URL:
https://yourdomain.com/hooks/deploy - Content type:
application/json - Secret: the same secret you put in hooks.json
- Events: select "Just the push event"
- Click "Add webhook"
Now every push to your repository triggers the webhook, which runs your deploy script. Your code is live within seconds of merging a pull request.
About zero-downtime deploys: The approach above has a brief moment of downtime during systemctl restart (usually under 1 second). For a student project or small app, this is fine. For production apps with real traffic, look into rolling restarts with multiple workers, or use a blue-green deployment strategy where you start the new version alongside the old one and switch Nginx to point to the new one only after it is healthy.
6. Monitoring with Free Tools
Your app is deployed, automated, and secured. But how do you know it is actually working? You need monitoring — and you do not need to pay for it.
Built-in Linux Tools
Your server already has powerful monitoring tools installed. Learn these first before reaching for external services.
sudo systemctl status myapp
# View the last 50 log lines
sudo journalctl -u myapp -n 50
# Follow logs in real time
sudo journalctl -u myapp -f
# View logs from a specific time period
sudo journalctl -u myapp --since "2026-03-17 10:00" --until "2026-03-17 12:00"
# Check disk usage
df -h
# Check memory usage
free -m
# Check CPU and top processes
htop # (install with: sudo apt install htop)
# Check Nginx access logs
sudo tail -f /var/log/nginx/access.log
# Check Nginx error logs
sudo tail -f /var/log/nginx/error.log
Simple Health Check Script
Create a script that checks whether your app is responding and alerts you if it is down. This runs via cron and sends you an email (or a webhook to Discord/Slack) if the health check fails.
#!/bin/bash
URL="https://yourdomain.com"
DISCORD_WEBHOOK="https://discord.com/api/webhooks/your-webhook-url"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 $URL)
if [ "$HTTP_STATUS" != "200" ]; then
MESSAGE="ALERT: $URL returned HTTP $HTTP_STATUS at $(date)"
echo "$MESSAGE" >> /home/deploy/healthcheck.log
# Send alert to Discord
curl -H "Content-Type: application/json" \
-d "{\"content\": \"$MESSAGE\"}" \
$DISCORD_WEBHOOK
# Attempt automatic restart
sudo systemctl restart myapp
echo "$(date): Auto-restart triggered" >> /home/deploy/healthcheck.log
fi
# Run every 5 minutes via cron
crontab -e
# Add this line:
*/5 * * * * /home/deploy/healthcheck.sh
This script curls your domain every 5 minutes. If it gets anything other than a 200 response, it logs the failure, sends a Discord notification, and attempts an automatic restart. Simple, effective, and free.
Uptime Kuma: Self-Hosted Monitoring Dashboard
If you want a proper monitoring dashboard with uptime graphs, response time tracking, and multi-channel alerting, Uptime Kuma is the best free, self-hosted option. It runs as a single Docker container on your VPS.
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy
# Run Uptime Kuma
docker run -d \
--name uptime-kuma \
--restart=always \
-p 3001:3001 \
-v uptime-kuma:/app/data \
louislam/uptime-kuma:1
Then add an Nginx location block or a separate server block to proxy port 3001, and you have a full monitoring dashboard at https://yourdomain.com/status (or a subdomain like status.yourdomain.com). Uptime Kuma supports alerts via email, Discord, Slack, Telegram, and dozens of other services.
Log Rotation
Logs grow forever unless you manage them. journalctl handles its own rotation, but if your app writes to custom log files, set up logrotate:
/home/deploy/myapp/logs/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 deploy deploy
postrotate
systemctl reload myapp > /dev/null 2>&1 || true
endscript
}
This keeps 14 days of compressed logs and automatically deletes older ones. Without log rotation, a busy app can fill your disk in weeks — and a full disk crashes everything.
Common Mistakes Checklist
Before you consider your deployment "done," verify you have not made these common mistakes. Every single one of them has caused production outages for real companies.
- Running as root. Your app should run as a non-root user. If an attacker exploits your app, they get whatever permissions the app has. Root means full server access.
- No firewall. Without UFW, every port on your server is open. Database ports (5432, 3306, 27017), Redis (6379), and debug ports are all accessible to anyone who scans your IP.
- No SSL. HTTP traffic is unencrypted. Passwords, tokens, and user data are transmitted in plain text. Let's Encrypt is free. There is no excuse.
- No process manager. If your app crashes at 2 AM and you started it with
node server.jsin a terminal, it stays down until you manually restart it. Use systemd or PM2. - No backups. If your server's disk fails, everything is gone. At minimum, back up your database daily. DigitalOcean offers automated weekly backups for $1/mo per droplet. For databases, set up a cron job with
pg_dumpormysqldumpthat uploads to an S3-compatible storage. - Secrets in code. Never commit .env files, API keys, or database passwords to Git. Use environment variables loaded from a protected file on the server.
- No log monitoring. If your app throws errors but nobody reads the logs, those errors compound until something breaks catastrophically. At minimum, check
journalctl -u myappdaily. Better: set up the health check script above. - Ignoring disk space. Docker images, log files, and old deployments accumulate. Run
df -hweekly. Set up a cron job to prune Docker (docker system prune -f) and old logs.
The complete stack you just built: Ubuntu 24.04 LTS with SSH key authentication, non-root user, UFW firewall, fail2ban, automatic security updates, Nginx reverse proxy with gzip and security headers, Let's Encrypt SSL with auto-renewal, systemd process management with auto-restart, a deploy script triggered by GitHub webhooks, and health check monitoring with Discord alerts. Total cost: $5–6/mo for the server, $0 for everything else. This is the same fundamental architecture that companies use to run production workloads serving millions of users — they just add more servers and a load balancer.