Contents

Nginx with Docker Tutorial: Static Sites and Reverse Proxying

This section has two parts: first, use Nginx to serve a static site; then put Nginx in front of other application containers as a reverse proxy. Finally, you'll use Caddy to obtain HTTPS certificates automatically.

The quickest static site

Create a directory named nginx-site and put the site files in public/:

nginx-site/
└── public/
    ├── index.html
    ├── style.css
    └── app.js

public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My Static Site</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main>
    <h1>My Static Site</h1>
    <p>Served by Nginx in a Docker container.</p>
    <p id="path"></p>
  </main>
  <script src="/app.js"></script>
</body>
</html>

public/app.js contains one line that displays the current URL path on the page (you'll use it later to test the SPA):

document.getElementById("path").textContent = "Current path: " + location.pathname;

The contents of style.css are up to you. Mount public/ at the official image's web root, /usr/share/nginx/html:

$ docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
806fb20a7c3ba980710bf43520c118d724346248d387f8743f8ba26e5e182345

$ curl -s http://localhost:8080/ | head -n 6
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My Static Site</title>

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
404

Windows equivalents (select a tab to switch):

docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

:ro means read-only; Nginx only needs to read the files. Open http://localhost:8080:

Static site served by an Nginx container
Nginx serves the static page, and its CSS and JavaScript load correctly

The last command requests a nonexistent path, /dashboard/settings, and gets 404. That's correct for a typical website. But for a single-page application (SPA) such as React or Vue, the frontend handles URL paths, so the server should return index.html. This requires a custom config file.

Custom configuration

The official image loads every .conf file in /etc/nginx/conf.d/. By default, there's only one: default.conf. The simplest approach is to replace it with your own file.

The first version of default.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # SPA: return index.html when a file isn't found
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache static assets for a long time
    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
}
SettingPurpose
try_files $uri $uri/ /index.htmlCheck for a file, then a directory; if neither exists, return /index.html
location ~* \.(js|css|...)$~* means a case-insensitive regular expression that matches static assets
gzip on + gzip_typesCompress text responses. HTML is compressed by default; list other types in gzip_types

Mount the config file too:

$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
89c2d3163e3db3bb4192bac6dfd48732c5f72d2d9d4584b9d3e1a32416c01f2a

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
200

$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css
HTTP/1.1 200 OK
Server: nginx/1.30.5
Date: Tue, 13 Oct 2026 00:40:31 GMT
Content-Type: text/css
Last-Modified: Tue, 13 Oct 2026 00:38:42 GMT
Connection: keep-alive
ETag: W/"6acd7d92-154"
Expires: Thu, 12 Nov 2026 00:40:31 GMT
Cache-Control: max-age=2592000
Cache-Control: public, immutable
Content-Encoding: gzip

Windows equivalents (select a tab to switch):

docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
  • /dashboard/settings now returns 200, so try_files is working.
  • Content-Encoding: gzip means the CSS was compressed.
  • But Cache-Control appears twice: expires 30d automatically adds Cache-Control: max-age=2592000, and add_header adds another one. Browsers can usually handle this, but it's not what we want.

Open http://localhost:8080/dashboard/settings in a browser. It displays index.html, and JavaScript reads the path as /dashboard/settings, so the frontend router can display the corresponding page:

index.html handles the SPA route
index.html is returned for a nonexistent path, and the frontend reads the path as /dashboard/settings

Fix the cache config by removing expires and using a single add_header with the complete value:

    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        add_header Cache-Control "public, max-age=2592000, immutable";
    }
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
868d331a579d208e646ee1be3a3d2a906720578a39418db2c4bd50febb892fd7

$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css | grep -E "Cache-Control|Content-Encoding"
Cache-Control: public, max-age=2592000, immutable
Content-Encoding: gzip

Windows equivalents (select a tab to switch):

docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

2592000 seconds is 30 days. immutable tells the browser not to check whether the file has changed during that period, so use it only for files whose names contain a version hash (for example, app.3f9a1c.js, which frontend build tools usually generate automatically).

Validate syntax and reload

After changing the config, validate it with nginx -t. If it passes, reload with nginx -s reload. You don't need to restart the container, and active connections won't be interrupted:

$ docker exec site nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

$ docker exec site nginx -s reload
2026/10/13 00:40:34 [notice] 27#27: signal process started

$ docker logs site 2>&1 | tail -n 4
2026/10/13 00:40:34 [notice] 1#1: reconfiguring
2026/10/13 00:40:34 [notice] 1#1: using the "epoll" event method
2026/10/13 00:40:34 [notice] 1#1: start worker processes
2026/10/13 00:40:34 [notice] 1#1: start worker process 32

The reconfiguring and start worker process log entries show that Nginx started new workers with the updated config.

If the syntax is invalid, nginx -t tells you where the problem is. For example, remove the semicolon from listen 80;:

$ sed 's/listen 80;/listen 80/' default.conf > broken.conf

$ docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/13 00:40:35 [emerg] 1#1: invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: configuration file /etc/nginx/nginx.conf test failed

Windows equivalents (select a tab to switch):

docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "${PWD}/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "%cd%/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t

The error mentions server_name on line 3, but the actual problem is the missing semicolon on line 2: Nginx treats the next line's server_name as an argument to listen. When an error is hard to understand, remember to check the previous line.

The preceding messages that start with /docker-entrypoint.sh: come from the official image's startup script. The can not modify ... (read-only file system?) message appears because we mounted the config file read-only with :ro, so the script can't add the IPv6 config automatically. You can ignore it.

You can validate a config file with just docker run ... nginx -t without actually starting the website. In CI, use this method to validate the config before deployment.

Generate config with environment variables

The official image supports templates. Put files with the .conf.template extension in /etc/nginx/templates/. At startup, environment variables replace ${variable} in the template, and the result is written to /etc/nginx/conf.d/.

templates/default.conf.template:

server {
    listen 80;
    server_name ${SERVER_NAME};
    root /usr/share/nginx/html;

    location / {
        add_header X-Environment ${APP_ENV};
        try_files $uri $uri/ /index.html;
    }
}
$ docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
77b6991d1d85b713cc741c86504d61e6a8dc867997e80219ec77b3f3c3be3165

$ docker logs tpl 2>&1 | grep -i template
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf

$ docker exec tpl cat /etc/nginx/conf.d/default.conf
server {
    listen 80;
    server_name example.com;
    root /usr/share/nginx/html;

    location / {
        add_header X-Environment staging;
        try_files $uri $uri/ /index.html;
    }
}

$ curl -sI http://localhost:8080/ | grep X-Environment
X-Environment: staging

Windows equivalents (select a tab to switch):

docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "${PWD}/templates:/etc/nginx/templates:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "%cd%/templates:/etc/nginx/templates:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine

${SERVER_NAME} and ${APP_ENV} are replaced with their environment variable values, but Nginx variables such as $uri are left alone: the startup script replaces only defined environment variables. You can deploy the same image to staging and production with different environment variables.

Package it as an image

Mounts are useful during development. For deployment, package the config and site files together in an image. Dockerfile:

FROM nginx:1.30-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
$ docker build -t my-site .

[+] Building 2.3s (8/8) FINISHED                                                                                                       docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 145B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/nginx:1.30-alpine                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 2B                                                                                                                  0.0s
 => [1/3] FROM docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94                       1.9s
 => => resolve docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94                       0.1s
 => => extracting sha256:e3320d02d5786716dcc40d9f6267baec5d3d2c46945082958050c3ac5ea23751                                                        0.3s
 => => extracting sha256:3d85d110b167fae23d8eb3a7060cd079682b682b04d295fc7bbdf454c9e722c8                                                        0.0s
 => => extracting sha256:22e5a8a110ec69c6af30325ccfe8bb0bfb94e5783117808061bae5115e1e3dcb                                                        0.0s
 => => extracting sha256:6b4dfb2e8f8a72d7ae1380b667f50faccdc19147869b2afa09aeddfdea59ce64                                                        0.0s
 => => extracting sha256:703c5424632fab32aa3327bebe6e5202ac53a93740062efb830f8188e580b9db                                                        0.0s
 => => extracting sha256:b335b7ac3a400afa780d0d89e0b778ee19dc3e3df95bed4b274a763af2264fd7                                                        0.0s
 => => extracting sha256:b27cf3f7c39dd0da0aa52a285f0c0cce938ac61472babfea353b1daa83586c49                                                        1.4s
 => [internal] load build context                                                                                                                0.1s
 => => transferring context: 1.51kB                                                                                                              0.0s
 => [2/3] COPY default.conf /etc/nginx/conf.d/default.conf                                                                                       0.1s
 => [3/3] COPY public/ /usr/share/nginx/html/                                                                                                    0.0s
 => exporting to image                                                                                                                           0.1s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:8bac0b1cbdfd6612d9eb656089bb18a18c4dba64cb8557aec5d427164a205865                                                0.0s
 => => exporting config sha256:5b6710aa0888ae70f084ad7911539cb362f4f919de995cf997898d37a9968baf                                                  0.0s
 => => exporting attestation manifest sha256:9be9e0e07eacaece4ad72b53c87a2a7f84b4fa99af726bf98a33229fbbf9e4eb                                    0.0s
 => => exporting manifest list sha256:af4d78aa254eab0a55dd440f1e220ed73d0a5141343d9ae0fe19890e8471cd04                                           0.0s
 => => naming to docker.io/library/my-site:latest                                                                                                0.0s
 => => unpacking to docker.io/library/my-site:latest                                                                                             0.0s

$ docker run -d --name site -p 8080:80 my-site
d614148de5a6c2243074f865a0c03266a8e81ba069733546412d4abe9ad64fa0

$ curl -s http://localhost:8080/ | grep "<h1>"
    <h1>My Static Site</h1>

$ docker images my-site
IMAGE            ID             DISK USAGE   CONTENT SIZE   EXTRA
my-site:latest   af4d78aa254e       92.8MB         26.1MB   U

The image is only slightly larger than nginx:1.30-alpine (93.7 MB). For frontend projects that need a build step (such as React or Vue), see Multi-stage builds: use Node.js to run npm run build in the first stage, then copy the output directory into the Nginx image in the second stage.

Reverse proxy: one entry point, multiple backends

In a common architecture, only Nginx exposes a port externally; the backend application containers do not:

User ──▶ nginx:80 ──┬──▶ app:3000   (/      → Express)
                      └──▶ api:8000   (/api/  → FastAPI)

Here, use the projects from the previous two sections as backends: Express from the Node.js app and FastAPI from the Python app.

proxy-demo/
├── compose.yaml
├── nginx.conf
├── app/      ← Express project from the Node.js section
└── api/      ← FastAPI project from the Python section

nginx.conf:

server {
    listen 80;

    location / {
        proxy_pass http://app:3000;
        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;
    }

    location /api/ {
        proxy_pass http://api:8000/;
        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 20m;
}
SettingPurpose
proxy_pass http://app:3000Forwards to port 3000 of the Compose service app (the container port)
HostPreserves the domain from the user's request; otherwise, the backend sees app:3000
X-Real-IP, X-Forwarded-ForPass the user's real IP; otherwise, the backend sees only the Nginx container's IP
X-Forwarded-ProtoTells the backend whether the original request used HTTP or HTTPS
client_max_body_size 20mSets the allowed upload size; the default is only 1 MB

compose.yaml:

services:
  nginx:
    image: nginx:1.30-alpine
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
      - api
    restart: unless-stopped

  app:
    build: ./app
    restart: unless-stopped

  api:
    build: ./api
    restart: unless-stopped

Only nginx has ports. Start the services:

$ docker compose up -d --build

[+] Building 2.6s (23/23) FINISHED
 => [internal] load local bake definitions                                                                                                       0.0s
 => => reading from stdin 885B                                                                                                                   0.0s
 => [app internal] load build definition from Dockerfile                                                                                         0.0s
 => => transferring dockerfile: 263B                                                                                                             0.0s
 => [api internal] load build definition from Dockerfile                                                                                         0.0s
 => => transferring dockerfile: 290B                                                                                                             0.0s
 => [api internal] load metadata for docker.io/library/python:3.14-slim                                                                          0.0s
 => [app internal] load metadata for docker.io/library/node:24-alpine                                                                            0.0s
 => [app internal] load .dockerignore                                                                                                            0.0s
 => => transferring context: 82B                                                                                                                 0.0s
 => [api internal] load .dockerignore                                                                                                            0.0s
 => => transferring context: 2B                                                                                                                  0.0s
 => [app 1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                      0.1s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.1s
 => [app internal] load build context                                                                                                            0.1s
 => => transferring context: 30.70kB                                                                                                             0.0s
 => [api 1/5] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                    0.1s
 => => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170                        0.1s
 => [api internal] load build context                                                                                                            0.1s
 => => transferring context: 655B                                                                                                                0.0s
 => CACHED [app 2/5] WORKDIR /app                                                                                                                0.0s
 => CACHED [app 3/5] COPY package.json package-lock.json ./                                                                                      0.0s
 => CACHED [app 4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                            0.0s
 => [app 5/5] COPY --chown=node:node . .                                                                                                         0.0s
 => CACHED [api 2/5] WORKDIR /app                                                                                                                0.0s
 => CACHED [api 3/5] COPY requirements.txt .                                                                                                     0.0s
 => CACHED [api 4/5] RUN pip install -r requirements.txt                                                                                         0.0s
 => CACHED [api 5/5] COPY . .                                                                                                                    0.0s
 => [app] exporting to image                                                                                                                     0.6s
 => => exporting layers                                                                                                                          0.1s
 => => exporting manifest sha256:30fc2b30e552f64db78c5b43bafd2ada5445c202a13517f1413cfbbf94b39fd3                                                0.0s
 => => exporting config sha256:ec00425160f63ab1fc89cf3ffb5cd5c91034dfe42b069922b6db604c6512dc94                                                  0.0s
 => => exporting attestation manifest sha256:bcd908d6b45f083d6373facdb8103d3cad7f5c8855744d68614f3c983fe2a3be                                    0.0s
 => => exporting manifest list sha256:ec1d3f8403286973e156b78f0db4566b8815c8e885f10c176b64c2c6cb2a75c5                                           0.0s
 => => naming to docker.io/library/proxy-demo-app:latest                                                                                         0.0s
 => => unpacking to docker.io/library/proxy-demo-app:latest                                                                                      0.4s
 => [api] exporting to image                                                                                                                     2.1s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:89889ca77d8a1fe2e05952bd565d6f4b777e87d2343df4d846ad16bec3752caa                                                0.0s
 => => exporting config sha256:6bc4e896d1388da575d77436bebb27a4b9941ed84896155384aef74eb4c6bf7c                                                  0.0s
 => => exporting attestation manifest sha256:c4130d12dd13346aa1cc105159cb8ff35177f368269cca237584c9e648fd2da7                                    0.0s
 => => exporting manifest list sha256:104bdf1ed74105f0bd21955d695d87d5225e50e4df7ab5715ac86eb551aac340                                           0.0s
 => => naming to docker.io/library/proxy-demo-api:latest                                                                                         0.0s
 => => unpacking to docker.io/library/proxy-demo-api:latest                                                                                      2.1s
 => [app] resolving provenance for metadata file                                                                                                 0.0s
 => [api] resolving provenance for metadata file                                                                                                 0.0s
[+] up 6/6
 ✔ Image proxy-demo-api         Built                                                                                                             2.7s
 ✔ Image proxy-demo-app         Built                                                                                                             2.7s
 ✔ Network proxy-demo_default   Created                                                                                                           0.0s
 ✔ Container proxy-demo-app-1   Started                                                                                                           0.4s
 ✔ Container proxy-demo-api-1   Started                                                                                                           0.7s
 ✔ Container proxy-demo-nginx-1 Started                                                                                                           1.2s

Test each path:

$ docker compose ps
NAME                 IMAGE               COMMAND                  SERVICE   CREATED         STATUS         PORTS
proxy-demo-api-1     proxy-demo-api      "uvicorn main:app --…"   api       7 seconds ago   Up 6 seconds   8000/tcp
proxy-demo-app-1     proxy-demo-app      "docker-entrypoint.s…"   app       7 seconds ago   Up 6 seconds   3000/tcp
proxy-demo-nginx-1   nginx:1.30-alpine   "/docker-entrypoint.…"   nginx     7 seconds ago   Up 5 seconds   0.0.0.0:8080->80/tcp, [::]:8080->80/tcp

$ curl -s http://localhost:8080/
{"message":"Hello from Express","node":"v24.21.0"}

$ curl -s http://localhost:8080/api/
{"message":"Hello from FastAPI"}

$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"item_id":7,"q":"proxy"}

$ curl -s http://localhost:8080/api/docs | grep -o "<title>.*</title>"
<title>Todo API - Swagger UI</title>
  • In ps, the PORTS for app and api are only 3000/tcp and 8000/tcp; neither is published to the host, so external access goes through Nginx.
  • / is forwarded to Express; /api/ and /api/items/7 are forwarded to FastAPI.
  • You can also access FastAPI's /docs at /api/docs. (In a real deployment, adjust the /openapi.json path referenced by the page using FastAPI's root_path setting.)

Test: the trailing slash in proxy_pass

When proxy_pass http://api:8000/; ends with /, Nginx replaces the matched /api/ from location with /. So /api/items/7 becomes /items/7 when forwarded to the backend. Without the trailing /, it is forwarded unchanged as /api/items/7, and the backend returns 404 if that path doesn't exist. This is one of the most common config mistakes. Let's try it.

Remove the trailing / and reload. This also exposes another pitfall:

$ sed -i 's#http://api:8000/;#http://api:8000;#' nginx.conf

$ grep "api:8000" nginx.conf
        proxy_pass http://api:8000;

$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
        proxy_pass http://api:8000/;

$ docker compose restart nginx
 Container proxy-demo-nginx-1 Restarting
 Container proxy-demo-nginx-1 Started

$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
        proxy_pass http://api:8000;

$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"detail":"Not Found"}

$ docker compose logs --tail 1 api
api-1  | INFO:     172.19.0.4:44428 - "GET /api/items/7?q=proxy HTTP/1.1" 404 Not Found

Here's what happens, in order:

  1. Use sed -i to edit nginx.conf on the host, then use grep to confirm the change.
  2. But /etc/nginx/conf.d/default.conf inside the container is still old. That's because sed -i (like many editors, including vim) saves by creating a new file and replacing the old one. A single-file mount is bound to the original file, so the container can't see the new file. Running nginx -s reload won't help.
  3. docker compose restart nginx restarts the container and remounts the file, making the new contents visible.
  4. Without the trailing slash, /api/items/7?q=proxy returns {"detail":"Not Found"}.
  5. The FastAPI log confirms it received GET /api/items/7, not /items/7.

To avoid issues with single-file mounts, mount the entire directory instead, for example ./nginx:/etc/nginx/conf.d:ro. The container can see files replaced inside the directory. Alternatively, always restart the container after changing the config.

502 Bad Gateway

502 means Nginx received the request but couldn't connect to the backend. Let's test a few common scenarios.

The backend port is incorrect (change app:3000 to app:3001 and restart Nginx):

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 0.001010s

$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1  | 2026/10/13 00:43:32 [error] 21#21: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3001/", host: "localhost:8080"

The request immediately returns 502, and the error log says Connection refused: the app host is reachable, but no process is listening on port 3001. You'll see the same error if the backend process is bound only to 127.0.0.1 or is still starting up.

Nginx 502 Bad Gateway error page
The 502 Bad Gateway error page shown in a browser

The backend container has stopped:

$ docker compose stop app
 Container proxy-demo-app-1 Stopping
 Container proxy-demo-app-1 Stopped

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 3.062307s

$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1  | 2026/10/13 00:43:39 [error] 20#20: *1 connect() failed (113: Host is unreachable) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3000/", host: "localhost:8080"

The request returns 502 after about 3 seconds with Host is unreachable. Nginx resolved app to an IP address at startup and remembered it; after the container stopped, that IP no longer responded. If the network doesn't immediately report that it can't connect, Nginx waits until the timeout (60 seconds by default) and returns 504 Gateway Timeout.

The backend service doesn't exist at all (start only Nginx, without app or api):

$ docker compose up -d --no-deps nginx
 Container proxy-demo-nginx-1 Started

$ docker compose ps -a nginx
NAME                 IMAGE               COMMAND                  SERVICE   CREATED         STATUS                                  PORTS
proxy-demo-nginx-1   nginx:1.30-alpine   "/docker-entrypoint.…"   nginx     3 seconds ago   Restarting (1) Less than a second ago

$ docker compose logs nginx 2>&1 | grep emerg | head -n 2
nginx-1  | 2026/10/13 00:43:48 [emerg] 1#1: host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
nginx-1  | nginx: [emerg] host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5

This time, Nginx can't even start: host not found in upstream "app". Nginx resolves the hostname in proxy_pass at startup. If resolution fails, it refuses to start, and the container keeps restarting because of restart: unless-stopped (Restarting (1)).

Troubleshoot a 502 in this order:

  1. Check whether the backend container is running: docker compose ps
  2. Check the service name and port in proxy_pass (use the container port, not the host port mapped by ports).
  3. Check that the backend listens on 0.0.0.0, not 127.0.0.1.
  4. Check that Nginx and the backend are on the same network. See Networking between containers.
  5. Check the Nginx error log: docker compose logs nginx | grep error

HTTPS: get certificates automatically with Caddy

Using Let's Encrypt certificates with Nginx requires separate certbot configuration, periodic renewal, and reloads. If you just want to add HTTPS quickly, Caddy automatically obtains, installs, and renews certificates with much less configuration.

Caddyfile:

example.com {
    reverse_proxy app:3000
}

compose.yaml:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    restart: unless-stopped

  app:
    build: ./app
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:

Requirements for using a real domain:

  • The domain's DNS points to this host.
  • Ports 80 and 443 are reachable from the internet (Let's Encrypt uses them for validation).
  • caddy_data stores the certificates. Don't delete it, or Caddy must request them again every time you recreate the service, which could trigger Let's Encrypt rate limits.

Try it locally

If you don't have a real domain locally, set the domain in Caddyfile to localhost. Caddy creates a local certificate authority and issues a certificate:

localhost {
    reverse_proxy app:3000
}
$ docker compose up -d
[+] up 5/5
 ✔ Network caddy-demo_default     Created                                                                                                         0.0s
 ✔ Volume caddy-demo_caddy_config Created                                                                                                         0.0s
 ✔ Volume caddy-demo_caddy_data   Created                                                                                                         0.0s
 ✔ Container caddy-demo-caddy-1   Started                                                                                                         0.3s
 ✔ Container caddy-demo-app-1     Started                                                                                                         0.6s

$ curl -sk https://localhost/
{"message":"Hello from Express","node":"v24.21.0"}

$ curl -sI http://localhost/ | head -n 3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://localhost/

$ docker compose logs caddy 2>&1 | grep -oE '"msg":"(enabling automatic[^"]*|obtaining certificate|certificate obtained successfully)"'
"msg":"enabling automatic HTTP->HTTPS redirects"
"msg":"enabling automatic TLS certificate management"
"msg":"obtaining certificate"
"msg":"certificate obtained successfully"

$ curl -svk https://localhost/ 2>&1 | grep -E "issuer|subject:"
*   subject:
*   issuer: CN=Caddy Local Authority - ECC Intermediate
  • https://localhost/ successfully reaches Express. Because the certificate wasn't issued by a public certificate authority, add -k to curl to skip verification; browsers will also show a warning.
  • Requests to http:// automatically receive a 308 Permanent Redirect to https://; no extra config is needed.
  • The logs show that Caddy automatically enabled HTTP-to-HTTPS redirects and certificate management, then obtained a certificate.
  • The certificate issuer is Caddy Local Authority. With a real domain, Caddy requests a certificate from Let's Encrypt instead.

Caddy's reverse_proxy passes headers such as X-Forwarded-For and X-Forwarded-Proto by default, so you don't need to configure them one by one as you do with Nginx.

Recap

  • The official image loads /etc/nginx/conf.d/*.conf and supports environment variable substitution in templates/*.conf.template.
  • Use try_files $uri $uri/ /index.html for an SPA. Don't use expires and add_header Cache-Control together.
  • A trailing / in proxy_pass removes the location prefix.
  • Expose a port externally only for the reverse proxy.

Exercises

Exercise 1: To change the FastAPI path in the reverse proxy example from /api/ to /backend/, what do you need to change?

Answer

Change location /api/ to location /backend/ in nginx.conf, keep the trailing / in proxy_pass http://api:8000/;, then run docker compose exec nginx nginx -s reload (with a single-file mount, use docker compose restart nginx instead).

Exercise 2: How can you check an Nginx config file for syntax errors with one command, without starting the website?

Answer
docker run --rm -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t

FAQ

Do I need to restart the container after editing nginx.conf?

First validate with docker exec <container> nginx -t, then run nginx -s reload; you don't need to restart. However, when using a single-file mount, saving in an editor may make the new file invisible to the container. See the test in this chapter.

What does 502 Bad Gateway mean?

Nginx can't connect to the backend. Check that the backend container is running, the service name and container port in proxy_pass are correct, and the backend listens on 0.0.0.0.

Should I use Nginx or Caddy?

Use Nginx when you need fine-grained control or your team already knows it. If you just want to add HTTPS quickly, Caddy needs much less configuration and renews certificates automatically.

Updated October 14, 2026