✦ For everyone, free.

Practical knowledge for real and everyday life

Home

14.3.2.2 Compose Proxy Service

A focused guide to Compose Proxy Service, connecting core concepts with practical Docker and container operations.

A Compose proxy service is a reverse proxy defined as one service among others in a Compose app stack, responsible for routing external traffic to the correct internal service, terminating TLS, and acting as the stack's single externally reachable entry point rather than exposing every backend service's ports directly to the host.

Defining the proxy as a first-class stack member

The proxy is declared like any other service in the stack, but with a distinguishing characteristic: it is typically the only service with ports published directly to the host, since every other service should be reachable only through it:

services:
  proxy:
    image: nginx:alpine
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./proxy/certs:/etc/nginx/certs:ro
    depends_on:
      - frontend
      - api

  frontend:
    build: ./frontend
    expose:
      - "3000"

  api:
    build: ./api
    expose:
      - "8080"

Using expose rather than ports for the backend services makes them reachable to other containers on the same Compose network without publishing them to the host at all, which means the only way to reach frontend or api from outside the Docker host is through the proxy.

Routing configuration referencing Compose service names

Within the proxy's own configuration, backend services are addressed by their Compose service name, which Docker's embedded DNS resolves automatically to the correct container's address on the shared network:

server {
    listen 443 ssl;
    server_name app.example.com;
    location / {
        proxy_pass http://frontend:3000;
    }
}

server {
    listen 443 ssl;
    server_name api.example.com;
    location / {
        proxy_pass http://api:8080;
    }
}

Because this resolution happens through Docker's internal DNS rather than a hardcoded IP address, the proxy configuration continues to work correctly even after a backend container is replaced during a deployment and receives a new internal IP address.

Startup ordering for the proxy

The proxy generally does not need its backends to be fully ready before it itself starts, since most reverse proxies handle a temporarily unreachable backend gracefully by returning an error to the client rather than failing to start; what matters more is that the proxy does not become the first point of failure if it starts before its configuration file or certificates are correctly mounted:

services:
  proxy:
    depends_on:
      api:
        condition: service_healthy

Where a stricter ordering is wanted, a health condition on depends_on ensures the proxy does not start routing traffic to a backend until that backend has reported itself healthy, avoiding a window of confusing errors immediately after the stack comes up.

Using a label-driven proxy instead of static configuration

Traefik and similar proxies designed for container environments can read routing rules directly from labels on other services in the same stack, removing the need to maintain a separate, manually written configuration file that must be kept in sync with the stack's actual services:

services:
  proxy:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"
      - "--entrypoints.websecure.address=:443"
    ports:
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

  api:
    image: my-api:latest
    labels:
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.services.api.loadbalancer.server.port=8080"

With this approach, adding a new routable service to the stack is purely a matter of adding labels to that service's own definition, and the proxy discovers it automatically the next time the stack is brought up or the new service starts.

Certificate management as part of the proxy service

TLS certificates for the entire stack are typically managed at the proxy layer, either through mounted certificate files or through an integrated automatic certificate provider:

services:
  proxy:
    image: traefik:v3.0
    command:
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=ops@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    volumes:
      - letsencrypt:/letsencrypt

volumes:
  letsencrypt:

Persisting the certificate storage volume across restarts is important, since without it the proxy would request new certificates from the certificate authority every time the container restarts, which is both slower and risks hitting rate limits imposed by the certificate authority for frequent issuance.

Restart and resilience for the proxy specifically

Because the proxy is the stack's single entry point, its own restart policy and health matter more than almost any other service in the stack; a stack with every backend running perfectly but a crashed proxy is entirely unreachable from outside:

services:
  proxy:
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost/healthz"]
      interval: 10s

Avoiding accidental bypass of the proxy

A common mistake when iterating on a stack is temporarily publishing a backend service's port directly to the host for convenience during debugging, and forgetting to remove that publication afterward:

services:
  api:
    ports:
      - "8080:8080"  # left in accidentally after a debugging session

This quietly reopens the exact bypass path the proxy pattern is meant to close, since the backend becomes reachable directly, without TLS termination or any routing logic the proxy would otherwise apply.

Common mistakes

  • Publishing backend service ports directly to the host in addition to routing through the proxy, leaving an unintended path that bypasses the proxy entirely.
  • Hardcoding a backend's container IP address in the proxy configuration instead of using its Compose service name, breaking the moment that container is replaced and receives a new address.
  • Not persisting the proxy's certificate storage volume, causing repeated, unnecessary certificate reissuance on every container restart.
  • Leaving the proxy without its own restart policy or health check, treating it as less critical than the services it fronts when it is in fact the stack's single point of external reachability.
  • Letting a manually maintained proxy configuration drift out of sync with the actual set of backend services defined in the stack, especially in setups that could use label-based automatic discovery instead.

A Compose proxy service works best when it is the stack's sole externally published entry point, references backends by their Compose service name rather than a fixed address, and is given the same or greater operational care, restart policy, health checking, certificate persistence, as the most important service in the entire stack, since it is structurally the one component every other service's reachability depends on.