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.
Quick answer: An Nginx reverse proxy sits in front of your app and forwards requests to it with proxy_pass (e.g. to localhost:3000), while passing Host, X-Forwarded-For and X-Forwarded-Proto headers so the backend sees the real client and scheme. Add HTTPS with Certbot in one command.
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
FAQ
What is a reverse proxy?
A server that receives client requests and forwards them to one or more backend apps, handling TLS termination, headers and routing. Nginx is the most common choice for this on Linux.
Which proxy headers do I actually need?
At minimum Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. Skip X-Forwarded-Proto and many apps generate http:// links behind HTTPS, causing redirect loops.
How do I add HTTPS to an Nginx proxy?
Run certbot --nginx. Let's Encrypt's Certbot obtains the certificate, edits your server block to serve on 443, and installs an auto-renewal timer.
Why am I getting a 502 Bad Gateway?
Nginx can't reach the backend behind proxy_pass — the app isn't running, is on a different port, or a firewall/SELinux is blocking the connection. Confirm the app is listening on the exact address you proxy to.
Can I run multiple apps on one server?
Yes. Use one server block per domain (name-based virtual hosts), each with its own proxy_pass target. Nginx routes by the Host header.