sehaj/learn
Docker/05 Networking

05.05 · build · 25 min

Project: wire the services together

Put Nginx in front, add a Redis cache, and split everything across a public and a private network so only one container is reachable from outside.

The brief

Step 4 layout
notes-frontendpublic side
notes-proxy · nginx · :8080
notes · API
notes-backendprivate side
notes · API
notes-db
notes-cache

What changed in the API

The API gets an optional REDIS_URL (npm install redis). The rule that matters: a cache must never be able to take the app down. So the API doesn't wait for Redis at startup, checks isReady before using it, and falls back to Postgres on any error. Responses carry an x-cache: hit or miss header so you can see it working.

src/server.ts
async function cached<T>(load: () => Promise<T>): Promise<{ value: T; hit: boolean }> {
if (cache?.isReady) {
  try {
    const hit = await cache.get(CACHE_KEY);
    if (hit) return { value: JSON.parse(hit) as T, hit: true };
  } catch {
    // fall through to the database
  }
}
const value = await load();
if (cache?.isReady) await cache.set(CACHE_KEY, JSON.stringify(value), { EX: 30 }).catch(() => {});
return { value, hit: false };
}

The Nginx config

project/docker/nginx/default.conf, bind-mounted read-only into the official image:

nginx/default.conf
server {
  listen 80;

  # Docker's embedded DNS. Resolving per request means nginx starts even if the API isn't up yet,
  # and follows the API to its new IP when the container is replaced.
  resolver 127.0.0.11 valid=10s;
  set $api http://notes:3000;

  location / {
      proxy_pass $api;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
  }
}

The resolver and variable look odd, so here's why. With a plain proxy_pass http://notes:3000;, Nginx looks the name up once, at startup. If the API isn't running yet, Nginx refuses to start:

❯terminal
$ docker run -d --name naive-proxy --network notes-frontend -v "$PWD/naive.conf":/etc/nginx/conf.d/default.conf:ro nginx:alpine
$ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' naive-proxy
exited exit=1
$ docker logs naive-proxy
[emerg] 1#1: host not found in upstream "notes" in /etc/nginx/conf.d/default.conf:4

Output trimmed. And even when it starts, it keeps the old IP after the API container is replaced. Using a variable makes Nginx ask Docker's DNS at request time instead.

Run it

From project/docker:

❯terminal
$ docker build -t notes-api api
$ docker network create notes-backend
$ docker network create notes-frontend
$ docker volume create notes-pgdata
$ docker run -d --name notes-db --network notes-backend \
-e POSTGRES_USER=notes -e POSTGRES_PASSWORD=notes -e POSTGRES_DB=notes \
-v notes-pgdata:/var/lib/postgresql postgres:18
$ docker run -d --name notes-cache --network notes-backend redis:8-alpine
$ docker run -d --name notes --network notes-backend \
-e DATABASE_URL=postgres://notes:notes@notes-db:5432/notes \
-e REDIS_URL=redis://notes-cache:6379 notes-api
$ docker network connect notes-frontend notes
$ docker run -d --name notes-proxy --network notes-frontend -p 8080:80 \
-v "$PWD/nginx/default.conf":/etc/nginx/conf.d/default.conf:ro nginx:alpine
$ docker ps --filter name=notes --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES STATUS PORTS
notes-proxy Up 8 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
notes Up 8 seconds (healthy) 3000/tcp
notes-cache Up 8 seconds 6379/tcp
notes-db Up 17 seconds 5432/tcp

Only notes-proxy has a host port. If you still have notes-pgdata from step 3, your old notes are already there.

Prove it works

❯terminal
$ curl -X POST localhost:8080/notes -H 'content-type: application/json' -d '{"text":"through nginx"}'
{"id":"52e5e286-3754-462b-866b-032a57f914af","text":"through nginx","createdAt":"2026-11-02T20:40:44.100Z"}
$ curl -s -D - -o /dev/null localhost:8080/notes | grep -iE '^(server|x-cache):'
Server: nginx/1.31.8
x-cache: miss
$ curl -s -D - -o /dev/null localhost:8080/notes | grep -iE '^(server|x-cache):'
Server: nginx/1.31.8
x-cache: hit

Server: nginx shows the request went through the proxy. The second read came from Redis.

Nginx can't see the database:

❯terminal
$ docker exec notes-proxy wget -qO- -T 3 http://notes-db:5432
wget: bad address 'notes-db:5432'
$ docker exec notes-proxy wget -qO- -T 3 http://notes:3000/healthz
{"status":"ok"}

The cache can fail safely:

❯terminal
$ docker stop notes-cache
$ curl -s -D - -o /dev/null localhost:8080/notes | grep -i '^x-cache'
x-cache: miss
$ docker logs notes 2>&1 | grep -i redis | tail -1
redis error: getaddrinfo ENOTFOUND notes-cache
$ docker start notes-cache

With Redis stopped, every request is a miss served from Postgres, and the API logs the problem instead of failing. Start Redis again and the client reconnects by itself.

Nginx survives the API restarting:

❯terminal
$ docker stop notes
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/notes
502
$ docker start notes
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/notes
200

A 502 while the API is down, then a 200 a few seconds after it starts (it waits for Postgres first), without touching Nginx.

Predict first

You replace the notes container with a new version, and it gets a new IP. What does Nginx do with this config?

Commit to an answer before you read on.
CheckpointProject step 4: wire the services together
  • Add the Redis cache to the API (or check out docker-step-04) and write the Nginx config.
  • Start all four containers on the two networks; only Nginx publishes a port.
  • Show a cache miss then a hit, that Nginx can't resolve notes-db, that the API survives Redis stopping, and that Nginx survives the API restarting.
  • Count the docker commands you ran. Keep that number in mind for Module 06.
STUCK? ▸

Nginx returns 502 constantly: the API isn't on notes-frontend (check docker network inspect notes-frontend). Always x-cache: miss: check REDIS_URL and that the cache is on notes-backend. Compare with git diff docker-step-04 -- project/docker.

What's next

That was nine commands, in the right order, with names repeated across them, to start one app. Module 06 turns all of it into a single compose.yaml and one docker compose up (06.06 Project: one-command dev environment).

Interview questions

MidfreqGood to knowDesign

Why put a reverse proxy like Nginx in front of an application container?

SeniorfreqMust-knowDesign

How should an application behave when its cache (Redis) is unavailable?

Report a mistakeEdit this page

Used in