You’ve got one VPS and three sites to host: a blog, a small app, and a marketing page. The obvious approach — run each in a container and map port 80 — falls apart immediately, because only one container can own port 80. The second one fails with a conflict, and you’re stuck.
The fix is a reverse proxy. One proxy container listens on ports 80 and 443, and every request gets routed to the right site container based on the domain in the request. Add automatic HTTPS and you’ve got a clean, repeatable way to pack many sites onto a single server, each isolated in its own container.
This guide builds that setup two ways — with Traefik (label-driven, hands-off certificates) and with Nginx (explicit config) — and covers the networking rules that make it work and the mistakes that quietly break it.
Why you need a reverse proxy
A host port can only be held by one listener. If two containers both try to publish 80:80, the second errors out with something like port is already allocated. You can’t host two sites on port 80 by mapping ports directly — there simply isn’t a second port 80 to give out.
A reverse proxy sidesteps this entirely. It’s the single thing bound to 80 and 443 on the host. When a request arrives, the proxy reads the Host header — the domain the visitor typed — and forwards the request to whichever container serves that domain. This is name-based virtual hosting, and it lets one IP and one pair of ports serve any number of sites.
The routing model
| blog.example.com | Proxy → blog container (internal port 80) |
|---|---|
| app.example.com | Proxy → app container (internal port 3000) |
| example.com | Proxy → marketing container (internal port 8080) |
| Shared host ports | Only the proxy publishes 80 and 443. Sites stay internal. |
The shared network: the key idea
The proxy and the site containers have to share a Docker network so the proxy can reach each site by name. The cleanest approach is one external network that every project joins. Create it once:
docker network create web
Every site’s Compose file then attaches to this web network as external, and so does the proxy. The proxy reaches each container by its service name on that network — no published ports on the sites at all. If the bridge-vs-DNS behavior here is fuzzy, the Docker networking explainer covers why containers resolve each other by name on a user-defined network.
networks:
web:
external: true
Option A: Traefik (automatic, label-driven)
Traefik reads Docker labels to discover services and build routes on the fly. Add a site, label it, and Traefik picks it up — no proxy config to edit. It also handles Let’s Encrypt certificates.
Here’s the proxy itself:
services:
traefik:
image: traefik:v3.1
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "[email protected]"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "letsencrypt:/letsencrypt"
networks:
- web
volumes:
letsencrypt:
networks:
web:
external: true
Now a site only needs labels describing its domain. Traefik does the rest:
services:
blog:
image: ghost:5
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.example.com`)"
- "traefik.http.routers.blog.entrypoints=websecure"
- "traefik.http.routers.blog.tls.certresolver=le"
- "traefik.http.services.blog.loadbalancer.server.port=2368"
networks:
web:
external: true
The Host(...) rule tells Traefik which domain maps to this container, certresolver=le requests a certificate automatically, and the server.port label tells Traefik which internal port the app listens on. Bring up another site by copying the file, changing the image, domain, and port. No edits to the proxy.
Option B: Nginx (explicit config)
If you’d rather write the routing yourself, an Nginx container works the same way: it owns 80/443 and proxies by server_name. This gives you precise control and a config you can read at a glance.
The proxy service:
services:
nginx:
image: nginx:1.27
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/nginx/certs:ro
networks:
- web
networks:
web:
external: true
A server block per site routes by domain to the container’s name on the web network:
server {
listen 80;
server_name blog.example.com;
location / {
proxy_pass http://blog:2368;
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_pass http://blog:2368 works because blog is the service name on the shared network and Docker’s embedded DNS resolves it. Add a second site by dropping in another .conf file with its own server_name and proxy_pass, then reload Nginx:
docker exec nginx nginx -s reload
For HTTPS with Nginx you add a TLS server block on 443 and obtain certificates with Certbot or an ACME companion container, then point the config at the cert files. The full single-site walkthrough — including the TLS block — is in the Nginx reverse proxy guide, which scales directly to multiple server_name blocks.
Traefik vs Nginx for this job
| Add a new site | Traefik: add labels, done. Nginx: add a .conf file and reload. |
|---|---|
| HTTPS certificates | Traefik: automatic via certresolver. Nginx: Certbot/ACME companion. |
| Config style | Traefik: labels on each container. Nginx: central config files. |
| Best when | Traefik: many/dynamic sites. Nginx: explicit control, familiar tooling. |
Keep the sites isolated from each other
Putting every site on the same web network lets the proxy reach them — but it also lets the sites reach each other, which you usually don’t want. The clean pattern is two networks per app: the shared web network for proxy traffic, and a private per-app network for that app’s own backend (its database, cache, and so on).
services:
app:
image: myapp:latest
networks:
- web # reachable by the proxy
- app_internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
db:
image: postgres:16
networks:
- app_internal # only the app can reach the database
environment:
POSTGRES_PASSWORD: change-me
networks:
web:
external: true
app_internal:
The database sits only on app_internal, so it’s invisible to the proxy and to the other sites. The app straddles both networks. This is the same tiered-isolation idea from the networking guide, applied across multiple projects on one host.
A repeatable setup checklist
Multi-site VPS with Docker
- Create one external network (docker network create web)
- Run a single reverse proxy that publishes 80 and 443
- Point each domain's DNS A record at the VPS IP
- Attach every site container to the web network
- Never publish ports on the site containers
- Give each app a private network for its own database
- Enable automatic HTTPS (Traefik certresolver or Certbot for Nginx)
For each new site you add later, the steps are the same: write its Compose file, join the web network, set its domain (label or server_name), and don’t publish any ports. The proxy handles the rest.
Wrapping up
Hosting several sites on one VPS isn’t about clever port tricks — it’s about putting one reverse proxy in front and letting it route by domain. The proxy owns 80 and 443, each site lives in its own container on a shared internal network, and HTTPS is handled centrally. Traefik makes this nearly automatic through labels; Nginx gives you explicit config you fully control. Pick based on whether you value automation or precision.
Whichever you choose, the rules don’t change: only the proxy publishes ports, sites talk to the proxy over a shared network, and each app keeps its database on a private network. Get those right and adding the fourth or tenth site is a copy-paste job. For the deeper networking model see Docker networking for beginners, for the single-site TLS detail see the Nginx reverse proxy guide, and browse more in the Docker & Containers guides.