Guide
Updated 8 min read

How to Run Multiple Apps on One VPS Without Port Collisions

The reverse proxy pattern explained for new self-hosters. Why port 80 only fits one app, and how Caddy/Traefik/nginx solve it.

How to Run Multiple Apps on One VPS Without Port Collisions

If you're new to self-hosting, the first multi-app moment is jarring. You've successfully got one app running on port 80. You install a second one. It also wants port 80. Two services can't bind the same port on the same IP. Now what?

The answer is "use a reverse proxy" — but new self-hosters often don't see how the pieces fit, and the explanations available online tend to assume they already know. Here's the explicit version.

What's actually happening

A "port" is a number, 1-65535, that an OS uses to route incoming connections to a specific process. When you run docker run -p 80:80 myapp, the kernel binds port 80 to that container. The next process that tries to bind 80 fails with "address already in use."

This is a feature, not a bug. Without it, two apps could intercept each other's traffic.

The way you run multiple HTTP services on one machine is: put one program on port 80 and 443 — the reverse proxy — and have it route incoming requests to many backends running on different (internal) ports.

The simplest mental model

Browser --→ Port 80/443 (Traefik / Caddy / nginx) --→ Backend on internal port 3001
                                                  \
                                                   →  Backend on internal port 3002
                                                  \
                                                   →  Backend on internal port 3003

Only one process holds 80/443. Backends listen on internal ports that aren't exposed to the public internet — they only need to be reachable by the proxy.

The proxy decides which backend to route to based on the request's Host header (api.example.com → backend A, app.example.com → backend B).

Caddy

Easiest learning curve. Ships with automatic HTTPS via Let's Encrypt out of the box. Configuration is a Caddyfile:

api.example.com {
    reverse_proxy localhost:3001
}

app.example.com {
    reverse_proxy localhost:3002
}

That's the whole config. SSL, redirects, and certificate renewal are automatic.

Traefik

Discovers services via Docker labels. Most-loved option for Docker-based stacks because new services configure themselves:

services:
  myapp:
    image: myapp:latest
    labels:
      - traefik.enable=true
      - traefik.http.routers.myapp.rule=Host(`app.example.com`)
      - traefik.http.routers.myapp.entrypoints=websecure
      - traefik.http.routers.myapp.tls.certresolver=letsencrypt
      - traefik.http.services.myapp.loadbalancer.server.port=3000

Add labels, redeploy, Traefik notices and starts routing. Great for many small services.

nginx

The classic choice. Manual configuration. More verbose, but battle-tested and infinitely tunable. Use it if you want maximum control or already know nginx.

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

A worked example

You want to host:

  • A blog at blog.example.com
  • An API at api.example.com
  • A dashboard at dashboard.example.com

All on one VPS.

Step 1: Pick internal ports

Assign each backend a free internal port. Convention: start at 3000 and increment.

  • Blog: 3001
  • API: 3002
  • Dashboard: 3003

Run each service bound only to localhost:

services:
  blog:
    image: ghost:latest
    ports:
      - "127.0.0.1:3001:2368"  # only localhost can reach it

127.0.0.1:3001:2368 exposes container port 2368 (Ghost's default) to localhost on host port 3001. Outside the box can't reach it.

Step 2: Reverse proxy

Run Caddy (easiest) on 80/443:

blog.example.com {
    reverse_proxy localhost:3001
}

api.example.com {
    reverse_proxy localhost:3002
}

dashboard.example.com {
    reverse_proxy localhost:3003
}

Done. Caddy gets certificates from Let's Encrypt automatically and routes by Host header.

Step 3: DNS

Point three A records (or one A + two CNAMEs) at your VPS IP:

blog.example.com.       A    1.2.3.4
api.example.com.        A    1.2.3.4
dashboard.example.com.  A    1.2.3.4

Wait for DNS propagation (a few minutes to an hour).

Step 4: Firewall

Only ports 22 (SSH) and 80/443 (HTTP/HTTPS) should be open on the public network:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Internal ports (3001-3003) are bound to localhost only — invisible from the public internet.

Common mistakes

"I exposed the backend port too"

If you wrote ports: - "3001:2368" instead of 127.0.0.1:3001:2368, the backend is reachable on the public IP at :3001. Not what you want.

Fix: rebind to localhost only. Force traffic through the proxy.

"I have two reverse proxies fighting over port 80"

This happens when you install Caddy and also leave nginx running. Pick one.

sudo systemctl stop nginx
sudo systemctl disable nginx

"Traefik routes to the wrong container after a redeploy"

Almost always a Docker network issue. Traefik must be on the same network as the backend container — and that network must be external: true in compose, otherwise compose recreates it with a new internal name. See the CORS-from-network-drift writeup for the deeper version of this bug.

"I get a 502 even though the backend is running"

The proxy is talking to the wrong port. Check that loadbalancer.server.port (Traefik) or proxy_pass (nginx) matches the container's internal listen port, not the host port mapping.

TL;DR

One reverse proxy on 80/443. Backends on localhost-only ports. DNS points everything at the VPS. Firewall blocks everything except 22/80/443. That's the entire pattern. Once you've set it up once, adding a fourth or fifth app is trivial — and the port-collision anxiety goes away for good.

ports
reverse-proxy
multi-app
vps
caddy

From across the StoicSoft network

Hand-curated reads on the same topic from sister sites in the StoicSoft family.

Keep reading

Guide
8 min

Self-hosted edge fixes need causal proof across mDNS, ACME, Caddy callbacks, and intermittent 404s

Fresh posts keep separating “the change worked once” from “the edge path is understood.” A Home Assistant user got IKEA Matter devices working after changing UniFi mDNS settings but still does not know why. A DevOps post says HAProxy ACME issuance worked…

Guide
7 min

OS End-of-Life Migrations Expose How Fragile a Multi-App VPS Really Is

A single-box VPS running eight apps is fine until the OS hits end of life and every app must move at once. How to make the OS-EOL migration boring instead of terrifying.

Guide
8 min

Self-Host a Public Load Balancer? Most Small Teams Need a Reverse Proxy Instead

Most one-VPS teams asking about self-hosting a public load balancer actually need a reverse proxy. Here is the decision framework, the realistic Caddy/Traefik/Nginx/HAProxy tradeoffs, and the no-downtime deploy pattern that does not require a multi-host LB.

Guide
8 min

When TLS at the Reverse Proxy Is Enough on a Single VPS (and When It Is Not)

If Nginx or Traefik terminates HTTPS on the same VPS that runs your app, internal TLS is usually unnecessary. The real question is where the trust boundary sits.

Guide
25 min

Nginx Reverse Proxy for Node.js: Complete VPS Guide

Set up Nginx as a reverse proxy for Node.js applications with SSL, load balancing, and production-ready configuration.

Guide
25 min

Traefik SSL on VPS: Complete Setup Guide

Set up Traefik as a reverse proxy with automatic Let's Encrypt SSL certificates. The foundation for hosting multiple apps on one VPS.