How to Host Multiple Docker Compose Apps Behind One Nginx Proxy

Run several self-hosted apps on one VPS by keeping each service on an internal port and letting Nginx handle the public web entry points.

Nginx reverse proxyDocker ComposeVPS architecture
What you learn

How to structure multi-app deployments, map domains to containers, keep app ports private, and test each layer without guessing.

Best for

Dashboards, admin tools, blogs, internal apps, and lightweight self-hosted stacks sharing one server.

Risk to watch

Exposing too many raw container ports creates unnecessary attack surface and makes your architecture harder to reason about.

Before you begin

  • An Ubuntu VPS with Docker, Docker Compose, and Nginx installed.
  • Two or more domain names or subdomains pointing to the server.
  • Basic comfort with SSH, docker compose, and editing Nginx config files.
  • UFW or another firewall configured to allow only the web ports you intend to expose.

A lot of beginners deploy the first app successfully and then get stuck on the second one. The app itself is rarely the problem. The problem is architecture. Only one service can listen publicly on port 80 or 443 at a time, so you need a front door that understands domains and forwards requests to the right internal app. That is the reverse proxy job.

Why this multi-app setup matters

Nginx lets you keep one public web entry point while many apps stay isolated behind it. That means you can run one app on 127.0.0.1:3001, another on 127.0.0.1:3002, and expose neither directly to the internet. Nginx receives the incoming request, checks the hostname, and forwards the traffic to the correct internal service.

Expected outcome: Each app has its own compose directory and localhost port, each domain reaches the correct service, and only Nginx needs to be public-facing.

Step 1: Choose a simple directory layout

A clean structure prevents confusion later. One practical layout looks like this:

/opt/apps/
├── app-one/
│   ├── compose.yml
│   └── .env
├── app-two/
│   ├── compose.yml
│   └── .env
└── nginx-notes.txt

Create the directories:

sudo mkdir -p /opt/apps/app-one /opt/apps/app-two
sudo chown -R $USER:$USER /opt/apps

Keep runtime data in named volumes or dedicated host directories, not mixed loosely into your Nginx config paths.

Step 2: Run each Compose app on its own local-only port

Here is a simple example for the first app:

services:
  app:
    image: nginx:alpine
    container_name: app-one
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:80"

And for the second app:

services:
  app:
    image: nginx:alpine
    container_name: app-two
    restart: unless-stopped
    ports:
      - "127.0.0.1:3002:80"

The key idea is the 127.0.0.1: prefix. That binds the published port to localhost only, so the app is reachable from the server itself but not directly from the public internet.

Start both stacks:

cd /opt/apps/app-one && docker compose up -d
cd /opt/apps/app-two && docker compose up -d

Test from the server:

curl -I http://127.0.0.1:3001
curl -I http://127.0.0.1:3002

If these local checks fail, fix the container layer before touching Nginx.

Step 3: Add Nginx server blocks for each domain

Create a site config for the first app:

sudo nano /etc/nginx/sites-available/app-one.conf

Example config:

server {
    listen 80;
    server_name app1.example.com;

    location / {
        proxy_pass http://127.0.0.1:3001;
        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;
    }
}

Create another for the second app:

server {
    listen 80;
    server_name app2.example.com;

    location / {
        proxy_pass http://127.0.0.1:3002;
        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;
    }
}

Enable both sites and test the config:

sudo ln -s /etc/nginx/sites-available/app-one.conf /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/app-two.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

If you later add HTTPS with Let’s Encrypt, Nginx remains the same front door. You just extend the server blocks or let Certbot update them.

Step 4: Point DNS and allow web traffic

Create DNS A records so each hostname points to your VPS:

  • app1.example.com → your server IP
  • app2.example.com → your server IP

Make sure the firewall allows web traffic:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status

After DNS propagates, test from outside the server:

curl -I http://app1.example.com
curl -I http://app2.example.com

Expected outcome and verification

When the setup is healthy:

  • Each container is running.
  • Each service responds on its localhost port.
  • Nginx passes requests to the correct backend based on hostname.
  • Only Nginx-facing web ports are public.

Helpful checks:

docker ps
ss -tulpn | grep -E '3001|3002|:80|:443'
sudo nginx -t
sudo journalctl -u nginx -n 50 --no-pager
curl -H 'Host: app1.example.com' http://127.0.0.1
curl -H 'Host: app2.example.com' http://127.0.0.1

Troubleshooting common problems

You see the default Nginx page instead of your app.
Your site config may not be enabled, the default site may still be winning, or the hostname does not match the server_name.

Nginx returns 502 Bad Gateway.
The backend app is not actually listening where Nginx expects. Test with curl http://127.0.0.1:3001 from the server first.

The app is publicly reachable on its raw port.
Change the port binding from 3001:80 to 127.0.0.1:3001:80, then recreate the container.

DNS points correctly, but the wrong app loads.
Check for duplicate server_name values or a catch-all default server block.

Warning: Do not treat random Docker and Nginx snippets as interchangeable. The safest multi-app setups are boring and explicit, with one clear hostname-to-port mapping for each app.

What to do next

Once the proxy layout is clean, add HTTPS and safer DNS habits. Continue with Reverse Proxy and HTTPS with Nginx and Let’s Encrypt.