Skip to content

Nginx Reverse Proxy

This page documents the Nginx reverse proxy that fronts the App-name frontend and backend containers on each EC2 host. Nginx is the single entry point the Cloudflare Tunnel points at: it routes / to the frontend and /api to the backend, and serves everything over plain HTTP because TLS is terminated at the Cloudflare edge.

Role in the stack

Nginx runs as its own service in the Compose stack alongside the frontend and backend containers. It is the only service the tunnel exposes (on port 80), which keeps the application containers off any public network and gives you one place to handle routing, timeouts, and request size limits.

flowchart LR
    user["User browser"] -->|HTTPS| cf["Cloudflare edge<br/>(TLS terminated)"]
    cf -->|encrypted tunnel| tun["cloudflared<br/>on EC2"]
    tun -->|HTTP :80| nginx["Nginx<br/>reverse proxy"]
    nginx -->|/| fe["frontend<br/>container"]
    nginx -->|/api| be["backend<br/>container"]

TLS handling

TLS is not handled on the host. Cloudflare terminates HTTPS at its edge, and the Cloudflare Tunnel carries traffic to the EC2 instance over its own encrypted connection. From Nginx's point of view all inbound traffic is plain HTTP on port 80, so the server block listens on 80 only — there are no certificates to manage on the host. The tunnel's public hostname (test.<your-domain> or <your-domain>) is configured to forward to <private-ip>:80, which is this Nginx.

Note

Because Cloudflare sits in front, use the X-Forwarded-* headers (set below) so the backend can recover the original client IP, host, and scheme.

The nginx/ folder

The reverse-proxy config lives in an nginx/ directory in the repository and is shipped to the host by the deploy pipeline (see Pipeline Overview). CI rsyncs it to the deploy directory so it lands at:

Environment Path
Test /opt/app-name/nginx/
Production /opt/app-name-prod/nginx/

A typical layout:

nginx/
└── default.conf      # the server block below

The Compose stack mounts this directory into the Nginx container, so updating the config on the host is just a matter of CI rsyncing new files (or editing them in place) and reloading Nginx.

Compose service

The Nginx service mounts the nginx/ folder read-only and publishes port 80 to the host (which the tunnel forwards to). It depends on the frontend and backend so it starts after them.

services:
  nginx:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - frontend
      - backend

  frontend:
    image: ghcr.io/<org>/app-name-frontend:latest
    restart: unless-stopped
    expose:
      - "80"

  backend:
    image: ghcr.io/<org>/app-name-backend:latest
    restart: unless-stopped
    expose:
      - "8000"

Tip

The frontend and backend use expose rather than ports — they are only reachable on the internal Compose network via Nginx, never published to the host. This keeps the application containers entirely behind the proxy.

Sample config

This server block proxies /api to the backend and everything else to the frontend. The container hostnames (frontend, backend) are the Compose service names, resolved on the internal network.

server {
    listen 80;
    server_name _;

    # Backend API
    location /api/ {
        proxy_pass         http://backend:8000/;
        proxy_http_version 1.1;
        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;
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
        proxy_read_timeout 300s;
    }

    # Frontend (SPA / static)
    location / {
        proxy_pass       http://frontend:80;
        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;
    }

    client_max_body_size 25m;
}

Note

Note the trailing slash on proxy_pass http://backend:8000/; — it strips the /api/ prefix so a request to /api/users reaches the backend as /users. Drop the trailing slash if your backend expects the /api prefix itself.

Reloading after config changes

After CI rsyncs new config (or you edit default.conf on the host), reload Nginx without dropping connections. Run these from the deploy directory (/opt/app-name or /opt/app-name-prod):

# Validate the config inside the running container
docker compose exec nginx nginx -t

# Graceful reload (no downtime) if validation passed
docker compose exec nginx nginx -s reload

If the container is not running yet, or you changed the Compose definition or the mounted file path, recreate the service instead:

docker compose up -d nginx

Warning

Always run nginx -t before reloading. A syntax error in default.conf will make a reload fail silently and a recreate fall into a crash loop, taking the public site offline.

Troubleshooting

502 Bad Gateway — Nginx cannot reach an upstream. Confirm the frontend/backend containers are healthy (docker compose ps) and that the proxy_pass hostnames match the Compose service names.

Changes not taking effect — verify the file actually landed at /opt/app-name/nginx/default.conf, that it is the path mounted in docker-compose.yml, and that you reloaded (nginx -s reload) afterwards.

Wrong client IP in backend logs — ensure the X-Forwarded-For / X-Real-IP headers above are set and that the backend trusts them.