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.
How to structure multi-app deployments, map domains to containers, keep app ports private, and test each layer without guessing.
Dashboards, admin tools, blogs, internal apps, and lightweight self-hosted stacks sharing one server.
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.
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.txtCreate the directories:
sudo mkdir -p /opt/apps/app-one /opt/apps/app-two
sudo chown -R $USER:$USER /opt/appsKeep 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 -dTest from the server:
curl -I http://127.0.0.1:3001
curl -I http://127.0.0.1:3002If 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.confExample 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 nginxIf 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 IPapp2.example.com→ your server IP
Make sure the firewall allows web traffic:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw statusAfter DNS propagates, test from outside the server:
curl -I http://app1.example.com
curl -I http://app2.example.comExpected 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.1Troubleshooting 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.
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.
