A reverse proxy for multiple domains is the only process listening on ports 80 and 443. It reads the hostname of each request and forwards it to the matching app, such as localhost:3000 or a Docker container. Every domain points to the same server IP with A and AAAA records. nginx uses one server block per site, Caddy one site block, Traefik labels on each container.
The second web app on a VPS is the point where a reverse proxy stops being optional. Until then one app listened on 443 directly. Now another one wants the same port. The answer is a single process at the front that sorts requests by hostname.
How a reverse proxy routes multiple domains
All domains resolve to the same IP. The proxy accepts the connection and looks at two things: the SNI name during the TLS handshake, to pick the certificate, and then the Host header, to pick the backend.
| Domain | DNS | Proxy forwards to |
|---|---|---|
example.com | A 192.0.2.10 | 127.0.0.1:3000 (Node app) |
blog.example.com | A 192.0.2.10 | 127.0.0.1:8080 (Ghost) |
example.net | A 192.0.2.10 | /var/www/example.net (static) |
Apps listen only locally, which also means nobody can reach them by bypassing the proxy. Getting domains to the server is covered in point a domain to your server, and whether subdomains should be CNAMEs or A records in CNAME vs A record.
nginx as a reverse proxy for several domains
One file per site in /etc/nginx/sites-available/, for example example.com:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
The last three lines are only needed for websockets. For the next site, copy the file and change server_name and the port. Then enable and certify:
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
ln -s /etc/nginx/sites-available/blog.example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d example.com -d www.example.com
certbot --nginx -d blog.example.com
certbot adds the listen 443 ssl lines and the HTTP to HTTPS redirect. Why one certificate per site beats one big certificate for everything is explained in the Let’s Encrypt certificate guide.
The catch-all server people forget
When a request arrives with a hostname nginx doesn’t know, sent straight to the IP or from someone else’s domain pointed at your server, nginx serves it from the default server, which is usually one of your sites. Better:
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
return 444;
}
ssl_reject_handshake (nginx 1.19.4 and later) aborts the TLS handshake without needing a certificate for the catch-all. 444 closes the connection without a response.
Caddy: several sites in a few lines
Caddy fetches certificates itself and redirects HTTP to HTTPS automatically. The complete Caddyfile for the three examples:
example.com, www.example.com {
reverse_proxy 127.0.0.1:3000
}
blog.example.com {
reverse_proxy 127.0.0.1:8080
}
example.net {
root * /var/www/example.net
file_server
}
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
reverse_proxy sets X-Forwarded-For and X-Forwarded-Proto by default and passes websockets through. If you run websites and a few apps, Caddy gets you there fastest. Fewer knobs also means you open the documentation more often for unusual cases.
Traefik: domains through Docker labels
Traefik reads routing from container labels. A minimal compose.yaml:
services:
traefik:
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "[email protected]"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
app:
image: ghcr.io/example/app:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`example.com`) || Host(`www.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.services.app.loadbalancer.server.port=3000"
Every further service gets the same labels with its own router name and Host rule. The volume for acme.json is not optional. Without it Traefik requests fresh certificates on every restart, and after five duplicates in a week Let’s Encrypt refuses more for those names.
Docker ports bypass ufw
This one surprises people regularly. Publish a container with -p 3000:3000 and port 3000 is reachable from the internet even if ufw doesn’t allow it. Docker inserts its own iptables rules, and they take effect before ufw’s. Your reverse proxy becomes one of two doors.
With a proxy on the host, bind containers to localhost only:
services:
app:
image: ghcr.io/example/app:latest
ports:
- "127.0.0.1:3000:3000"
If the proxy runs in Docker, as with Traefik above, apps don’t need ports at all. Both share a Docker network and Traefik talks to the container directly. From another machine, nc -zv 192.0.2.10 3000 should fail to connect.
The app behind the proxy: real IPs and HTTPS
Behind the proxy, your app sees every request as plain HTTP from 127.0.0.1. Left alone, it logs the wrong IPs, builds http:// links or loops on login redirects. The proxy sets the headers; the app has to read them and must be told to trust the proxy:
| Framework | Setting |
|---|---|
| Express | app.set('trust proxy', 'loopback') |
| Django | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') |
| Laravel | TrustProxies middleware with the proxy address |
| Rails | config.assume_ssl = true plus trusted proxies |
Trust only your own proxy’s address. Accepting X-Forwarded-For from anyone lets clients invent their IP and sidestep rate limits and bans.
Which proxy for which server
| Criterion | nginx | Caddy | Traefik |
|---|---|---|---|
| Certificates | Needs certbot | Automatic | Automatic |
| Config | Files per site | One Caddyfile | Docker labels |
| Wildcard via DNS-01 | certbot plugin | Custom build with DNS module | DNS provider in resolver |
| Strength | Flexible, everywhere | Shortest config | Dynamic containers |
My rule of thumb: everything in Docker with services coming and going, Traefik. Otherwise Caddy if you want to be done quickly, nginx if you already know it or need caching and rate limiting. For dynamic subdomains add a wildcard certificate.
Once the proxy is in place, a new site is one DNS record and one config block. For the next project you can check name availability at betadomain. An .io domain costs €61.99 a year, and the yearly prices for every extension apply to renewals too.
Step by step
- Point every domain at the serverCreate A and AAAA records for each domain with the server's IP addresses.
- Move apps to local portsRun each app on 127.0.0.1 with its own port, or inside a Docker network, never directly on 80 or 443.
- Add the proxy configurationCreate one nginx server block, one Caddyfile site block or one set of Traefik router labels per domain.
- Forward the right headersPass Host, X-Forwarded-For and X-Forwarded-Proto to the app, plus Upgrade and Connection for websockets.
- Get certificatesWith nginx, run certbot --nginx per site. Caddy and Traefik request certificates automatically on first use.
- Add a catch-all serverDefine a default server that rejects unknown hostnames so none of your sites is served for them by accident.
Found your domain? You can check and register it directly at buy a domain – yearly price from €2.99.
FAQ
Do I need a separate IP address for each domain?
No. With SNI the browser sends the hostname during the TLS handshake, so one IP can serve any number of HTTPS sites with their own certificates. Separate IPs are only needed in rare edge cases today.
Which reverse proxy is easiest?
Caddy, because it obtains and renews certificates without extra configuration and a site takes three lines. nginx is more widespread and flexible but needs certbot alongside. Traefik shines when everything already runs in Docker.
Why does my app generate http links or show the wrong client IP?
Behind the proxy the app only sees a plain HTTP connection from 127.0.0.1. Pass the Host, X-Forwarded-For and X-Forwarded-Proto headers and tell the app to trust the proxy. The setting depends on the framework.
Can two reverse proxies listen on port 443 at the same time?
No, only one process can bind a given IP and port. If nginx and Traefik must coexist, one listens elsewhere and the other forwards to it. Picking one is simpler.