Host Multiple Docker Projects behind a Shared Nginx Proxy
- Host Multiple Docker Projects behind a Shared Nginx Proxy
This guide explains how to configure a single Debian VPS to run multiple independent Docker Compose projects, each accessible via its own domain name (e.g., https://project1.com and https://project2.com) using a single shared Nginx reverse proxy and automated Let's Encrypt SSL certificates.
Prerequisites
- A Debian server with Docker and the Docker Compose plugin installed. (See Install-docker-and-docker-compose-on-debian-server-using-a-script)
- Domain names with DNS
Arecords pointing to your server's public IP address. - A user with docker privileges.
Directory Structure
To decouple the global proxy setup from the application repositories, organize your server's home directory as follows:
/home/debian/
├── nginx/ # Global Nginx reverse proxy and Let's Encrypt companion
├── project1/ # First application stack (e.g., django, postgres)
└── project2/ # Second application stack
Step 1: Create a Shared Docker Network
Create a shared Docker network named webproxy that acts as the bridge between the global proxy container and your individual projects.
Why an external network?
Creating an external network allows independent docker-compose.yml stacks to communicate with each other securely without exposing internal ports to the host machine.
Step 2: Configure the Global Nginx Proxy
- Create the
nginxdirectory and navigate into it:
-
Create a
docker-compose.ymlfile: ??? details "Click to expand docker-compose.yml for nginx-proxy"services: nginx-proxy: image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - "80:80" - "443:443" volumes: - certs:/etc/nginx/certs:ro - vhost:/etc/nginx/vhost.d - html:/usr/share/nginx/html - /var/run/docker.sock:/tmp/docker.sock:ro networks: - webproxy acme-companion: image: nginxproxy/acme-companion container_name: nginx-proxy-acme restart: always environment: - DEFAULT_EMAIL=your-default-email@example.com - NGINX_PROXY_CONTAINER=nginx-proxy hl_lines="6" volumes: - certs:/etc/nginx/certs:rw - vhost:/etc/nginx/vhost.d:rw - html:/usr/share/nginx/html:rw - acme:/etc/acme.sh - /var/run/docker.sock:/var/run/docker.sock:ro depends_on: - nginx-proxy networks: - webproxy volumes: certs: vhost: html: acme: networks: webproxy: external: true -
Spin up the global proxy stack:
Step 3: Configure Your Applications
For each project (e.g., project1 and project2), configure the application service to connect to the external webproxy network and specify the proxy routing variables.
- Navigate to your project directory (e.g.,
/home/debian/project1). - Create a
.envfile containing your domains and credentials:
# Domain settings
VIRTUAL_HOST=project1.com
VIRTUAL_PORT=8000
LETSENCRYPT_HOST=project1.com
LETSENCRYPT_EMAIL=your-email@example.com
# Database settings
POSTGRES_DB=maavita_prod
POSTGRES_USER=postgres
POSTGRES_PASSWORD=secure_password
- Configure your application's
docker-compose.ymlto interpolate those variables dynamically:
services:
fastapi-app:
build:
context: ./fastapi-app
dockerfile: Dockerfile.prod
# Note: Do NOT expose host ports 80/443 here. The proxy container handles external traffic.
environment:
- VIRTUAL_HOST=${VIRTUAL_HOST}
- VIRTUAL_PORT=${VIRTUAL_PORT}
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL}
- POSTGRES_HOST=db
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
depends_on:
db:
condition: service_healthy
networks:
- default # For communication with local database / redis
- webproxy # For communication with the shared Nginx proxy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Local service configurations...
networks:
webproxy:
external: true
- Spin up the application stack:
Verification
Once both stacks are running:
-
nginx-proxydetects the new containers on thewebproxynetwork. -
acme-companionrequests SSL certificates for the configuredLETSENCRYPT_HOSTdomains. - Access
https://project1.comin your browser. It should display your application interface with an active SSL lock icon.
Auto-renewal
The Let's Encrypt companion will automatically renew certificates 30 days before they expire.
Troubleshooting shared Reverse Proxy & SSL Issues
Deploying double stacks on a single VPS with shared ingress can lead to a few routing and build failure points.
SSL Verification Fails with a 404 Error
During certificate generation, the Let's Encrypt CA server queries http://your-domain.com/.well-known/acme-challenge/<token> to verify ownership.
If this returns a 404 Not Found response:
- Root Cause: The Nginx proxy container and the ACME companion container are not sharing the static
htmlchallenge volume correctly, or the mount paths are incorrect. - Fix: Ensure both containers define the exact same named volume
htmlmapped to their default webroots: nginx-proxy:- html:/usr/share/nginx/html:ro(read-only is sufficient)acme-companion:- html:/usr/share/nginx/html:rw(requires read-write access to generate challenge tokens)
SSL Request Timed Out (Firewall Block)
The validation logs report a connection timeout trying to reach the challenge file:
- Root Cause: Let's Encrypt contacts your domain strictly on port
80(HTTP) first. If your cloud security group or local firewall (e.g.ufwon Debian/Ubuntu) blocks incoming TCP traffic on port80, the verification fails. -
Fix: Open ports
80and443in your firewall:
Domain Resolves to the Wrong IP or returns NXDOMAIN
The validation logs report:
- Root Cause: The domain's DNS
Arecord has not been configured, or is pointing to a legacy server IP. - Fix: Log into your DNS provider (e.g. OVH, Cloudflare) and create/update the
Arecord pointing the root@and any subdomains (likedocs) to the exact public IP address of your new VPS. ??? details "Verify DNS locally" To verify DNS propagation on your machine before running a renewal, query the domain records:
SSL Unrecognized Name or HTTP 503 after VPS Reboot
Accessing the site over HTTPS returns ERR_SSL_UNRECOGNIZED_NAME_ALERT (SSL unrecognized name), and HTTP returns 503 Service Temporarily Unavailable.
- Root Cause: The host VPS or Docker daemon restarted, but the application container lacked a restart policy (such as
restart: unless-stopped) and remained stopped. Since the proxy was up but the backend container was down, the shared proxy had no active configuration or certificate mapping for the domain. -
Fix:
- Manually start the containers:
- Add
restart: unless-stoppedto the service definitions in your production compose file:
services: fastapi-app: build: context: ./fastapi-app dockerfile: Dockerfile.prod restart: unless-stopped- Recreate the containers on the VPS to apply the policies:
Troubleshooting Docker & uv Build Issues
When building Python applications using modern dependency tools like uv inside multi-stage Docker builds, you may hit environment incompatibilities.
Container Crash: exec /app/.venv/bin/uvicorn: no such file or directory
Even though the image builds successfully, the container exits immediately with code 255 on startup.
- Root Cause: The build context is polluted. Without a
.dockerignorefile, theCOPY . .instruction copies your local macOS.venvdirectory directly into the Docker image, overwriting the clean Linux.venvbuilt byuv sync. -
Fix:
- Create a
.dockerignorefile in the same folder as yourDockerfileto exclude local developer caches and environments:
- Clean any host-polluted files directly on the VPS if they were transferred:
- Rebuild the container discarding cached layers:
- Create a
uv Error: No interpreter found for Python 3.12.4 in search path
During the build stage, uv sync fails to locate the correct Python executable even though the base image is python:3.12-slim.
- Fix: Change
.python-versionto target the minor release line (e.g.3.12) rather than a strict patch version. This allowsuvto use any3.12.xsystem interpreter. -
Edit
.python-version: -
Specify
requires-python = ">=3.12"inpyproject.tomlinstead of a locked version. - Set
UV_PYTHON_PREFERENCE=only-systemin theDockerfile.prodbuilder stage to enforce path matching.
Mixed Content: Styles Not Applied on Production (FastAPI/Uvicorn)
When requesting pages from the container over https://, page layouts render as unstyled plain text, and the browser console logs mixed content errors.
- Root Cause: The reverse proxy terminates SSL. The browser connects via
https://, but the proxy forwards the request internally viahttp://on port 8000. Uvicorn does not read headers likeX-Forwarded-Protoby default, so Starlette/FastAPI's dynamic URL resolverurl_for('static', ...)generates insecurehttp://links which modern browsers block. - Fix: Configure the ASGI web server (Uvicorn) to trust and parse proxy headers. Update the entry point command in your
Dockerfile.prod(e.g. Dockerfile.prod):