The brief
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.
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:
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:
$ 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-proxyexited 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:
$ 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 PORTSnotes-proxy Up 8 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcpnotes Up 8 seconds (healthy) 3000/tcpnotes-cache Up 8 seconds 6379/tcpnotes-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
$ 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.8x-cache: miss$ curl -s -D - -o /dev/null localhost:8080/notes | grep -iE '^(server|x-cache):'Server: nginx/1.31.8x-cache: hit
Server: nginx shows the request went through the proxy. The second read came from Redis.
Nginx can't see the database:
$ docker exec notes-proxy wget -qO- -T 3 http://notes-db:5432wget: bad address 'notes-db:5432'$ docker exec notes-proxy wget -qO- -T 3 http://notes:3000/healthz{"status":"ok"}
The cache can fail safely:
$ 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 -1redis 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:
$ docker stop notes$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/notes502$ docker start notes$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/notes200
A 502 while the API is down, then a 200 a few seconds after it starts (it waits for Postgres first), without touching Nginx.
You replace the notes container with a new version, and it gets a new IP. What does Nginx do with this config?
- 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
dockercommands 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
Why put a reverse proxy like Nginx in front of an application container?
How should an application behave when its cache (Redis) is unavailable?