Virtualization

Run multiple sites on one VPS with Docker and nginx proxy

You have a single VPS with a single public IPv4, but you want to serve three, four, or a dozen websites from it, each with its own domain, its own application stack, and its own SSL certificate. The old way was to install nginx, configure server blocks manually, run Certbot for every domain, and hope you never accidentally break the syntax. The better way in 2026 is Docker + nginx-proxy: each site lives in its own container, the reverse proxy auto-detects new containers, and Let's Encrypt certificates are issued automatically. No manual reloads, no conflicting configs, no port fights.

Key takeaways

  • nginx-proxy listens on host port 80/443 and routes traffic to Docker containers based on the domain name, without manual nginx config per site.
  • Each site runs in its own container with its own stack (PHP, Node.js, static files, whatever), the proxy never touches the application code.
  • Let's Encrypt SSL via nginx-proxy companion is fully automatic: point a domain at the VPS, add the VIRTUAL_HOST and LETSENCRYPT_HOST labels, and the certificate appears.
  • This setup runs on a single Linux VPS with as little as 2 GB of RAM, depending on the number and weight of your applications.

用 Docker 和 nginx-proxy 在一台 VPS 上运行多个网站,无需手动配置 nginx,SSL 证书自动生成。

Run multiple websites on one VPS with Docker and nginx-proxy, no manual nginx configuration, SSL certificates generated automatically.

Prerequisites

  • A running VPS with Ubuntu 24.04 LTS (this guide) or Debian 12, a Linux VPS with full root access is required.
  • Docker and Docker Compose v2 installed (docker compose, not the old docker-compose).
  • A domain name (or several) pointed at the VPS IPv4. Wildcard DNS (e.g. *.yourdomain.com → VPS IP) is fine.
  • Ports 80 and 443 open on the firewall (ufw / nftables / firewalld).
  • Basic familiarity with the Linux command line and a YAML editor (nano, vim, or cat).

Why run multiple sites inside Docker instead of bare metal

Running ten sites on a single bare-metal nginx works, until it doesn't. One site needs PHP 8.2, another needs Python 3.12 with a specific library version. One leaks memory; the others suffer. If you ever need to move a site to another server, you rebuild the entire config or copy a messy webroot. Docker solves the isolation problem: each application runs in its own environment with its own dependencies, its own file system, and its own resource limits. The host only runs Docker and the reverse proxy. You can stop, restart, or replace any container without touching the others.

nginx-proxy adds the convenience layer: it reads Docker container labels (environment variables), generates nginx reverse proxy configuration on the fly, and reloads automatically. The companion container handles Let's Encrypt. This means adding a new site is two things: start the container with the right labels, and point the domain at the VPS IP. That's it.

Step 1, Setting up the nginx-proxy stack

We run nginx-proxy as a Docker container itself, listening on host ports 80 and 443. Create a directory for the proxy stack and a docker-compose.yml file:

mkdir -p ~/nginx-proxy
cd ~/nginx-proxy
nano docker-compose.yml

Paste the following configuration:

services:
  nginx-proxy:
    image: nginxproxy/nginx-proxy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./certs:/etc/nginx/certs:ro
      - ./vhost:/etc/nginx/vhost.d
      - ./html:/usr/share/nginx/html

  nginx-proxy-acme:
    image: nginxproxy/acme-companion:latest
    restart: unless-stopped
    environment:
      - [email protected]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./certs:/etc/nginx/certs
      - ./vhost:/etc/nginx/vhost.d
      - ./html:/usr/share/nginx/html
      - ./acme:/etc/acme.sh

volumes:
  certs:
  vhost:
  html:
  acme:

Replace [email protected] with your actual email, Let's Encrypt uses it for expiry notifications. The nginx-proxy-acme companion container handles certificate issuance and renewal. Both containers share volumes so the proxy can serve the certificates.

Start the stack:

docker compose up -d

Verify: docker compose ps should show both containers as Up. Check the logs:

docker compose logs nginx-proxy-acme

You should see something like acme-companion: acme companion started, no errors.

At this point, the proxy is live on ports 80 and 443, but it has nowhere to route traffic yet. Any HTTP request hitting port 80 without a matching domain will get a default 503.

Step 2, Firewall: open ports 80 and 443

If you use ufw (Ubuntu default firewall), open the web ports:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload

If you use nftables or firewalld, adapt accordingly. The key: the host firewall must permit inbound traffic on 80 and 443 before you point a domain at the VPS, or Certbot (through the acme companion) will fail to validate ownership.

Step 3, Deploy your first site (static HTML example)

Create a new directory for the first site, with its own docker-compose.yml. This site runs Nginx serving static files:

mkdir -p ~/site-one
cd ~/site-one
nano docker-compose.yml
services:
  site-one:
    image: nginx:alpine
    restart: unless-stopped
    expose:
      - "80"
    environment:
      - VIRTUAL_HOST=site-one.example.com
      - LETSENCRYPT_HOST=site-one.example.com
      - [email protected]
    volumes:
      - ./html:/usr/share/nginx/html:ro

The key lines are VIRTUAL_HOST (tells nginx-proxy which domain to route) and LETSENCRYPT_HOST (tells the acme companion to request a certificate). Note: we use expose: "80" instead of ports, the container exposes port 80 only to the internal Docker network, not to the host. The proxy reaches it via the internal Docker network automatically.

Create a simple landing page:

mkdir html
echo '<h1>Site One, Running on Docker</h1>' > html/index.html

Start the container:

docker compose up -d

Verify: After 30 to 60 seconds (Let's Encrypt needs to validate and issue the certificate), visit https://site-one.example.com. You should see the "Running on Docker" page over HTTPS, with a valid Let's Encrypt certificate. Check the proxy logs for any SSL errors:

cd ~/nginx-proxy
docker compose logs nginx-proxy-acme

If the certificate appears, you are done with site one.

Step 4, Add a second site (WordPress example)

WordPress needs a separate container for the application and a database. The same proxy handles both domains without any conflict:

mkdir -p ~/site-two
cd ~/site-two
nano docker-compose.yml
services:
  db:
    image: mariadb:11
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: db_root_password
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wp_user
      MARIADB_PASSWORD: wp_password
    volumes:
      - db_data:/var/lib/mysql

  wordpress:
    image: wordpress:6.7-php8.3-apache
    restart: unless-stopped
    expose:
      - "80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD: wp_password
      WORDPRESS_DB_NAME: wordpress
      VIRTUAL_HOST: site-two.example.com
      LETSENCRYPT_HOST: site-two.example.com
      LETSENCRYPT_EMAIL: [email protected]
    volumes:
      - wp_data:/var/www/html

volumes:
  db_data:
  wp_data:

Start both containers:

docker compose up -d

After 60 to 90 seconds, visit https://site-two.example.com. You will see the WordPress installation wizard. The same nginx-proxy on ports 80/443 now routes site-one.example.com to the static Nginx container and site-two.example.com to the WordPress Apache container, simultaneously, no port conflicts, no extra config.

Step 5, Understanding what happens under the hood

When you started the first site container, nginx-proxy detected the VIRTUAL_HOST label and generated an nginx server block automatically. It then reloaded its configuration without you running nginx -s reload. The acme companion saw LETSENCRYPT_HOST, checked for an existing certificate, and ran the HTTP-01 challenge against the VPS. Once validated, it stored the certificate in the shared volume and the proxy served it.

When you started the second site, the same process repeated. The proxy now has two upstream containers, and the server_name directive in nginx picks the correct one based on the Host header of the incoming request. No port mapping on the host; expose: "80" is enough because all containers are on the same Docker bridge network.

Step 6, DNS and rDNS for reliable domain routing

For the setup to work reliably, each domain must resolve to the VPS IP. If you manage DNS separately, create an A record for each domain (e.g. site-one.example.com A 103.xxx.xxx.xxx). If you own a wildcard domain, *.example.com A 103.xxx.xxx.xxx covers all subdomains at once. Many VPS providers also let you set reverse DNS (rDNS / PTR) on the IPv4. For a multi-site VPS, set the rDNS to a generic hostname like vps.yourdomain.com, do not set it per site, since PTR is one per IP.

ComponentValue
Proxy listening ports (host)80 (HTTP), 443 (HTTPS)
Docker socket mount/var/run/docker.sock:/tmp/docker.sock:ro
Required container labelsVIRTUAL_HOST, LETSENCRYPT_HOST
Network exposureexpose: "80" (internal, not host port binding)
Certificate storage./certs on host, shared between proxy and acme-companion

Troubleshooting

502 Bad Gateway from nginx-proxy

The proxy can see the container but cannot connect to it. Cause: the application container is not listening on port 80 inside the container, or it crashed. Check: docker ps -a to see if the app container is running. Check logs: docker compose logs app (from the app's directory). Fix: ensure the container has expose: "80" and that the application actually listens on port 80 inside.

SSL certificate not issued (http-01 challenge fails)

Let's Encrypt cannot reach the domain on port 80. Check three things: (1) the domain A record resolves to the VPS IP, dig site-one.example.com +short should show the IP. (2) Port 80 is open on the host firewall, sudo ufw status or sudo nft list ruleset. (3) The site container is running, docker ps. If the proxy was started before the app container, wait 30 seconds and check the acme-companion logs again: docker compose logs nginx-proxy-acme.

All sites go to the same default page

This happens when VIRTUAL_HOST is missing or misspelled in the container environment. Verify with: docker inspect site-one | grep VIRTUAL_HOST. If it is missing, edit the docker-compose.yml, then docker compose up -d --force-recreate to apply.

Port 80 or 443 already in use

Something else on the host is already listening on port 80 or 443 (maybe a bare-metal nginx or Apache). Stop it: sudo systemctl stop nginx and sudo systemctl disable nginx, then restart the proxy stack: docker compose restart.

FAQ

How many sites can I run on one VPS with this setup?

The nginx-proxy itself imposes no limit, the constraint is your VPS resources (RAM, CPU, disk). A 2 GB RAM VPS can comfortably run 3 to 5 lightweight sites (static, Node.js, small WordPress instances). A 4 GB or 8 GB VPS can run 10 to 20 depending on traffic. Monitor memory with docker stats and add swap if needed.

Does nginx-proxy support WebSocket?

Yes. nginx-proxy passes the Upgrade and Connection headers by default, so WebSocket-based applications (live chat, real-time dashboards, collaborative editors) work without extra configuration.

Can I use Cloudflare's proxy (orange cloud) with this setup?

Yes, but with one caveat: Cloudflare terminates the SSL and opens a new connection to your VPS. If you enable Cloudflare proxy, the acme companion must see the real client IP via the CF-Connecting-IP header, which nginx-proxy does not handle automatically. Workaround: set Cloudflare's SSL/TLS mode to Full (strict), and on the VPS side, use a real Let's Encrypt certificate (the acme companion still issues it normally). Ensure the VPS firewall allows traffic from Cloudflare IP ranges only, or keep it open to all.

Do I need to restart nginx-proxy when I add a new site?

No. nginx-proxy listens on the Docker socket and reloads its configuration automatically when a container with a VIRTUAL_HOST label starts or stops. You only need to start the new container, the proxy picks it up within seconds.

How does this compare to running multiple sites with Apache virtual hosts?

Manual vhost config works but is inflexible. Each site version change means editing configs and restarting the server. With Docker, each site is a self-contained unit: you can update a site's image, roll back, or move it to another VPS by shipping the docker-compose.yml and volumes. For multi-site hosting on a single VPS, Docker + nginx-proxy is the standard approach in 2026.

Can I use this with non-HTTP services like SMTP or SSH?

No. nginx-proxy is an HTTP reverse proxy (Layer 7). For non-HTTP services, use a different approach (Docker's port mapping directly, or a dedicated VPN). For email, see our guide on setting up SMTP on a SMTP VPS.

Related articles

用 Docker 和 nginx-proxy 在一台 VPS 上运行多个网站

这个方法适用于需要在一台 VPS 上同时运行多个不同技术栈的网站(比如一个站点用 PHP,另一个用 Node.js)。通过 Docker 容器隔离每个站点,nginx-proxy 自动根据域名将流量路由到对应的容器,并且自动申请和续期 Let's Encrypt SSL 证书。部署新站点只需要启动容器并设置 VIRTUAL_HOST 和 LETSENCRYPT_HOST 标签即可,不需要手动修改 nginx 配置。一台 2GB 内存的越南 VPS 就能稳定运行 3-5 个轻量级站点,适合个人开发者或小团队。

Note: This guide is for general reference. Every system and infrastructure has its own specifics, so test each step in a safe environment and consult a qualified engineer before applying it in production.