The setup: one server with a public IP, multiple services running on internal ports (Docker containers, apps, whatever). Nginx sits at the front, listens on 80/443, and routes traffic to the right backend based on the domain name. All SSL terminates at Nginx.
This is how this blog runs. Ghost is on port 2368 internally, Nginx handles HTTPS and passes requests through.
Install Nginx
sudo apt update
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
Directory structure
Nginx on Ubuntu uses two directories:
/etc/nginx/sites-available/— your config files live here/etc/nginx/sites-enabled/— symlinks to the ones that are active
The default config in sites-enabled/default conflicts with everything. Disable it:
sudo rm /etc/nginx/sites-enabled/default
Basic reverse proxy config
Create /etc/nginx/sites-available/myapp.conf:
server {
listen 80;
server_name myapp.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
Enable it:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t before reloading. It validates syntax without touching the running config. I've never skipped this and been burned — I have been burned skipping it.
Why those proxy headers matter
X-Real-IP / X-Forwarded-For — without these, your backend sees 127.0.0.1 as the client IP for every request. Your logs are useless, fail2ban can't see real IPs, and rate limiting by IP won't work.
X-Forwarded-Proto — tells the backend whether the original request was HTTP or HTTPS. Ghost uses this to decide whether to redirect to HTTPS. Without it, you get redirect loops.
Upgrade / Connection — needed for WebSocket connections. Ghost's admin panel uses WebSockets. Without these headers it just silently breaks.
Add SSL with Certbot
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com
Certbot edits your config automatically to add the HTTPS block and HTTP→HTTPS redirect. It also sets up auto-renewal via a systemd timer. Check it's working:
sudo systemctl status certbot.timer
sudo certbot renew --dry-run
After Certbot: what your config looks like
Certbot adds the SSL bits but keeps your proxy config intact. The final result looks roughly like this:
server {
listen 443 ssl;
server_name myapp.example.com;
ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
server {
listen 80;
server_name myapp.example.com;
return 301 https://$host$request_uri;
}
Multiple services on one server
Same pattern, separate config files. Each domain gets its own file in sites-available:
# /etc/nginx/sites-available/blog.conf → port 2368
# /etc/nginx/sites-available/api.conf → port 4000
# /etc/nginx/sites-available/app.conf → port 8080
No conflicts as long as each server_name is unique. The routing is entirely by hostname — Nginx reads the Host header and picks the matching server block.
Tuning for production
Add this to your server block for anything handling file uploads or large request bodies:
client_max_body_size 50m;
Default is 1MB. Ghost's image upload will fail silently above that limit.
For services behind the proxy that take time to respond (build tools, AI endpoints, anything slow):
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
Default read timeout is 60 seconds. If your upstream takes longer, Nginx returns a 504 gateway timeout before the backend even finishes.
Debugging common problems
502 Bad Gateway — the backend isn't running or isn't listening on the port you told Nginx to use. Check with ss -tlnp | grep 3000.
301 redirect loop — missing X-Forwarded-Proto header. Your backend is redirecting HTTP→HTTPS but Nginx is sending it as HTTP internally.
WebSocket connections failing — missing Upgrade/Connection headers.
Can't upload files — missing or too-low client_max_body_size.
# Check Nginx error log when something breaks
sudo tail -f /var/log/nginx/error.log