Skip to content

How to Run Multiple Websites on One VPS Using Docker

Host several websites on one VPS with Docker: a reverse proxy routes by domain to each container, sharing ports 80/443 with automatic HTTPS. Nginx and Traefik examples.

SDSysadmin Desk November 14, 2026 8 min read
Photographic illustration for How to Run Multiple Websites on One VPS Using Docker: server racks with glowing container blocks

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.

Frequently asked questions

Can I run multiple websites on one VPS with Docker?

Yes. Run each site in its own container, then put a single reverse proxy in front that listens on ports 80 and 443 and routes each domain to the right container by hostname. Only the proxy publishes ports; the site containers stay on an internal Docker network.

Why can't each container just bind port 80?

Only one process can listen on a given host port at a time. If two containers both publish 80:80 the second fails with a port conflict. A reverse proxy solves this by being the single thing on 80/443 and forwarding to each site's container internally based on the requested domain.

Should I use Nginx or Traefik as the reverse proxy?

Both work well. Traefik discovers containers automatically from Docker labels and handles HTTPS certificates for you, which suits dynamic setups. Nginx (often via nginx-proxy or a hand-written config) gives you precise control and is familiar to most admins. For label-driven automation, Traefik is less work; for explicit config, Nginx.

How do the sites get HTTPS certificates?

Use Let's Encrypt. Traefik can request and renew certificates automatically when you add a certresolver to a service's labels. With Nginx, a companion ACME container or Certbot obtains and renews the certs, which the proxy then serves. Either way, point each domain's DNS at the VPS first.

Do the website containers need to publish ports?

No, and they shouldn't. Only the reverse proxy publishes 80 and 443 to the host. Each site container exposes its port on the shared Docker network where the proxy can reach it, but never maps to a host port. Publishing site ports directly would expose them and bypass the proxy.

How does the proxy know which container serves which domain?

By the Host header in each request. The proxy matches the requested domain against its routing rules — server_name in Nginx, or a Host(`example.com`) label in Traefik — and forwards to the matching container. One IP and one set of ports can serve many domains this way (name-based virtual hosting).

Sources & further reading

Official vendor documentation referenced while writing this guide.

SD

Sysadmin Desk

Infrastructure & Cloud

Hands-on guidance for infrastructure, virtualization, and containers — Hyper-V, VMware, Docker, and the day-to-day operations work that keeps environments running.

MCSA Guru provides independent, educational IT guidance. Microsoft, Windows, Windows Server, Microsoft 365, Exchange, and Microsoft Teams are trademarks of Microsoft Corporation; Docker is a trademark of Docker, Inc. MCSA Guru is not affiliated with or endorsed by Microsoft or Docker. Always test changes in a safe environment before applying them in production.

Related guides

Fixing something right now?

Jump straight into the guide library or search for the exact error or task you are dealing with.