Skip to content

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

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.

docker network create webproxy

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

  1. Create the nginx directory and navigate into it:
mkdir -p /home/debian/nginx && cd /home/debian/nginx
  1. Create a docker-compose.yml file: ??? 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
    

  2. Spin up the global proxy stack:

docker compose up -d

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.

  1. Navigate to your project directory (e.g., /home/debian/project1).
  2. Create a .env file 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
  1. Configure your application's docker-compose.yml to 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
  1. Spin up the application stack:
docker compose up -d

Verification

Once both stacks are running:

  • nginx-proxy detects the new containers on the webproxy network.
  • acme-companion requests SSL certificates for the configured LETSENCRYPT_HOST domains.
  • Access https://project1.com in 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 html challenge volume correctly, or the mount paths are incorrect.
  • Fix: Ensure both containers define the exact same named volume html mapped 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:

Verification error details: Timeout during connect (likely firewall problem)
  • Root Cause: Let's Encrypt contacts your domain strictly on port 80 (HTTP) first. If your cloud security group or local firewall (e.g. ufw on Debian/Ubuntu) blocks incoming TCP traffic on port 80, the verification fails.
  • Fix: Open ports 80 and 443 in your firewall:

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

Domain Resolves to the Wrong IP or returns NXDOMAIN

The validation logs report:

DNS problem: NXDOMAIN looking up A for docs.project2.com - check that a DNS record exists
  • Root Cause: The domain's DNS A record 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 A record pointing the root @ and any subdomains (like docs) 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:
    nslookup docs.project2.com
    

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:

    1. Manually start the containers:
    docker compose -f docker-compos-prod.yml up -d
    
    1. Add restart: unless-stopped to the service definitions in your production compose file:
    services:
      fastapi-app:
        build:
          context: ./fastapi-app
          dockerfile: Dockerfile.prod
        restart: unless-stopped
    
    1. Recreate the containers on the VPS to apply the policies:
    docker compose -f docker-compos-prod.yml up -d --force-recreate
    

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 .dockerignore file, the COPY . . instruction copies your local macOS .venv directory directly into the Docker image, overwriting the clean Linux .venv built by uv sync.
  • Fix:

    1. Create a .dockerignore file in the same folder as your Dockerfile to exclude local developer caches and environments:
    .venv
    __pycache__
    *.pyc
    .env
    .pytest_cache
    
    1. Clean any host-polluted files directly on the VPS if they were transferred:
    rm -rf fastapi-app/.venv
    
    1. Rebuild the container discarding cached layers:
    docker compose build --no-cache fastapi-app
    

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-version to target the minor release line (e.g. 3.12) rather than a strict patch version. This allows uv to use any 3.12.x system interpreter.
  • Edit .python-version:

    3.12
    
  • Specify requires-python = ">=3.12" in pyproject.toml instead of a locked version.

  • Set UV_PYTHON_PREFERENCE=only-system in the Dockerfile.prod builder 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 via http:// on port 8000. Uvicorn does not read headers like X-Forwarded-Proto by default, so Starlette/FastAPI's dynamic URL resolver url_for('static', ...) generates insecure http:// 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):
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]