Put the whole Docker course together: containerize notes-api, PostgreSQL and an nginx frontend with Compose, health checks, CI and a server deployment.

Bishal Biswas
Self Learner
The final project
You have learned a lot in this course: images, Dockerfiles, volumes, networks, Compose, health checks, security, CI and deployment. Now you will use all of it together to build one complete app, from an empty folder to a running website on a server.
You will containerize a small full-stack app. “Full-stack” means it has every part a real web app has:
- a frontend: the web page people see, served by nginx,
- a backend: the
notes-apiNode.js API you have used all course, - a database: PostgreSQL, which stores the notes.
When you are done, you will be able to open a page, type a note, and see it saved in the database. More importantly, you will have a project structure you can copy for your own apps.
Note: This is a long lesson. Take it one part at a time. Each part ends with something you can run and check, so you always know you are on the right track.
What you will build
Browser
|
| http://localhost:8080
v
+------------------+
| web (nginx) | serves index.html and app.js
| | forwards /api/... to the api
+------------------+
| "frontend" network
v
+------------------+
| api (Node.js) | GET /health, GET /notes, POST /notes
+------------------+
| "backend" network (internal, no internet)
v
+------------------+
| db (Postgres) |----- pgdata volume (the notes live here)
+------------------+
Some design choices, and the lessons they come from:
| Choice | Why | Lesson |
|---|---|---|
Only web publishes a port |
One door from outside; the api and db stay private | Publishing Ports |
| Two networks | The web server cannot talk to the database at all | Bridge Networks and Container DNS |
| A named volume for the database | Notes survive container updates | Docker Volumes |
| Health checks on every service | Services start in the right order and you can see their state | Startup Order with depends_on and Health Checks |
nginx proxies /api/ |
The browser talks to one address, so there are no CORS problems | Reverse Proxies and Load Balancers |
CORS (Cross-Origin Resource Sharing) is a browser rule that blocks a page on one address from freely calling an API on another address. Because the page and the API share the same address here (localhost:8080), the rule never gets in the way.
Project layout
Create this folder structure:
notes-app/
compose.yaml # the production-ready description of the app
compose.override.yaml # extra settings for local development only
.env.example # example settings, safe to commit
.env # your real settings, never committed
.gitignore
api/
Dockerfile
.dockerignore
package.json
package-lock.json
src/
server.js
web/
Dockerfile
nginx.conf
public/
index.html
app.js
Each service gets its own folder with its own Dockerfile. That keeps each build context small, as you learned in The Build Context and .dockerignore.
Part 1: the API
package.json
{
"name": "notes-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"pg": "^8.16.0"
}
}
pg is the most common PostgreSQL client for Node.js. Run npm install once inside api/ to create package-lock.json. The Dockerfile needs that lock file, so npm ci installs the exact same versions every time. See Semver and Lockfiles.
src/server.js
This is the same notes-api from earlier lessons, now with real storage in PostgreSQL:
import http from 'node:http';
import pg from 'pg';
const port = Number(process.env.PORT ?? 3000);
// A pool keeps a few database connections open and reuses them
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Create the table on startup if it does not exist yet
await pool.query(`
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
function sendJson(res, status, data) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(data));
}
async function readJson(req) {
let body = '';
for await (const chunk of req) body += chunk;
return JSON.parse(body || '{}');
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/health') {
return res.end('ok');
}
if (req.method === 'GET' && req.url === '/notes') {
const { rows } = await pool.query(
'SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 50'
);
return sendJson(res, 200, rows);
}
if (req.method === 'POST' && req.url === '/notes') {
const { text } = await readJson(req);
if (typeof text !== 'string' || text.trim() === '') {
return sendJson(res, 400, { error: 'text is required' });
}
// $1 is a placeholder: pg sends the value separately, which prevents SQL injection
const { rows } = await pool.query(
'INSERT INTO notes (text) VALUES ($1) RETURNING id, text, created_at',
[text.trim().slice(0, 500)]
);
return sendJson(res, 201, rows[0]);
}
sendJson(res, 404, { error: 'not found' });
} catch (err) {
console.error(err);
sendJson(res, 500, { error: 'internal error' });
}
});
// 0.0.0.0 so requests from other containers can reach it
server.listen(port, '0.0.0.0', () => {
console.log(`notes-api listening on port ${port}`);
});
// docker stop and docker compose down send SIGTERM: finish requests, close the pool, exit
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down');
server.close(async () => {
await pool.end();
process.exit(0);
});
});
Three Docker details are worth noticing:
- It reads all settings from environment variables (
PORT,DATABASE_URL). The same image works on your laptop and on the server. See Configuring Containers with Environment Variables. - It listens on 0.0.0.0, not 127.0.0.1.
- It handles SIGTERM, so stopping is fast and clean. See Graceful Shutdown.
api/.dockerignore
node_modules
npm-debug.log
.env
.git
Dockerfile
.dockerignore
api/Dockerfile
# syntax=docker/dockerfile:1
# ---------- Stage 1: install production dependencies ----------
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# A cache mount keeps npm's download cache between builds (it is not saved in the image)
RUN npm ci --omit=dev
# ---------- Stage 2: the final, small image ----------
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
# Run as the unprivileged "node" user that the official image provides
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
This uses ideas from several lessons: Layer Caching (package files first), Multi-Stage Builds, BuildKit Cache Mounts, Running as Non-Root and the exec form of CMD from CMD vs ENTRYPOINT.
There is no HEALTHCHECK in this Dockerfile. You will define the health check in compose.yaml instead, so all three services are configured in one place. Both ways work.
Check point: build it on its own to make sure the Dockerfile is correct.
cd notes-app
docker build -t notes-api:dev ./api
=> [deps 4/4] RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev 3.1s
=> [stage-1 5/5] COPY src ./src 0.0s
=> exporting to image 0.2s
=> => naming to docker.io/library/notes-api:dev 0.0s
Part 2: the frontend
The frontend is just two static files. nginx serves them, and forwards API calls.
web/public/index.html
The course code blocks do not have an HTML highlighter, so this one is shown as plain text:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
form { display: flex; gap: 0.5rem; }
input { flex: 1; padding: 0.5rem; }
</style>
</head>
<body>
<h1>Notes</h1>
<form id="note-form">
<input id="note-text" placeholder="Write a note" maxlength="500" required>
<button>Add</button>
</form>
<ul id="notes"></ul>
<script src="app.js"></script>
</body>
</html>
web/public/app.js
const list = document.querySelector('#notes');
const form = document.querySelector('#note-form');
const input = document.querySelector('#note-text');
async function loadNotes() {
// A relative URL: the browser calls the same address the page came from
const res = await fetch('/api/notes');
const notes = await res.json();
list.replaceChildren(
...notes.map((note) => {
const li = document.createElement('li');
li.textContent = note.text; // textContent, not innerHTML: safe from HTML injection
return li;
})
);
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
await fetch('/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: input.value }),
});
input.value = '';
loadNotes();
});
loadNotes();
web/nginx.conf
This file replaces nginx’s default site. It does two jobs: serve the static files, and proxy (forward) every request that starts with /api/ to the api container.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# The frontend: static files
location / {
try_files $uri $uri/ /index.html;
}
# The backend: /api/notes is forwarded to http://api:3000/notes
location /api/ {
# "api" is the compose service name; Docker's DNS turns it into an IP address.
# The trailing slash removes the /api/ prefix before forwarding.
proxy_pass http://api: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;
}
}
The trailing slash in proxy_pass http://api:3000/; matters. With it, /api/notes becomes /notes on the api. Without it, the api would receive /api/notes and answer 404.
web/Dockerfile
# syntax=docker/dockerfile:1
FROM nginx:1.29-alpine
# Our site config replaces the default one
COPY nginx.conf /etc/nginx/conf.d/default.conf
# The static frontend
COPY public/ /usr/share/nginx/html/
# The nginx image already sets EXPOSE 80 and the right CMD
That is the whole Dockerfile. The official nginx image does the rest.
Check point:
docker build -t notes-web:dev ./web
Do not run it on its own yet. nginx checks that the name api exists when it starts, and there is no api until Compose creates one. You will see that error in the “Common mistakes” section.
Part 3: the compose file
Now connect the three services. The complete compose.yaml is in the code panel. Let us walk through it piece by piece.
Settings in .env
Compose reads .env in the project folder and fills in every ${VARIABLE} in the compose file. Commit an example file, and keep the real one private:
# .env.example: copy to .env and change the values
TAG=dev
POSTGRES_PASSWORD=devpassword
# .gitignore
.env
api/node_modules
cp .env.example .env
Tip: Use only letters and numbers in
POSTGRES_PASSWORD. The password is placed insideDATABASE_URL, and characters like@,/or:would break the URL.openssl rand -hex 24makes a good one.
You learned this in Environment Variables and Secrets in Compose.
The db service
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_DB: notes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
${POSTGRES_PASSWORD:?...}means “stop with this message if the variable is missing”. Much better than starting a database with an empty password.pg_isreadyis a small tool inside the postgres image that answers “is the database accepting connections?”.- No
ports:. Nothing outside Docker can reach the database.
The api service
api:
image: ghcr.io/your-name/notes-api:${TAG:-dev}
environment:
PORT: "3000"
DATABASE_URL: postgres://notes:${POSTGRES_PASSWORD}@db:5432/notes
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
image:names the image with a tag variable. LocallyTAGisdev. On the server it will be a real version like1.0.0.@db:5432uses the service namedbas the host name.condition: service_healthymakes the api wait until PostgreSQL really accepts connections, not just until its container has started. Without it, theCREATE TABLEon startup would often fail.- The health check uses Node’s built-in
fetch, because the Alpine Node image has nocurl. - The last block hardens the container, exactly as in Container Security Best Practices.
The web service
web:
image: ghcr.io/your-name/notes-web:${TAG:-dev}
ports:
- "127.0.0.1:8080:80"
networks:
- frontend
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 10s
timeout: 3s
retries: 3
- The only published port. It is bound to
127.0.0.1, so on a server only a local reverse proxy can reach it. On your own computer,http://localhost:8080still works. - It waits for a healthy api, which also solves nginx’s “host not found” problem at startup.
wgetcomes with Alpine’s BusyBox tools, so it is available in the nginx Alpine image.--spiderchecks the page without downloading it.
Networks, volumes and logging
networks:
frontend:
backend:
internal: true # no internet access for containers only on this network
volumes:
pgdata:
web is only on frontend, and db is only on backend. So the web server cannot reach the database even if someone breaks into it. The api is on both and acts as the bridge between them. The full file also sets log rotation for every service with a YAML anchor, as in Logging Drivers and Log Rotation.
compose.override.yaml: development extras
compose.yaml only says which images to run. For local work you also want Compose to build them, and to update the containers when you save a file. Put that in compose.override.yaml. Compose merges it automatically when it is present:
# compose.override.yaml: local development only (do not copy this to the server)
services:
api:
build: ./api
develop:
watch:
- action: sync+restart # copy changed code in, then restart the app
path: ./api/src
target: /app/src
- action: rebuild # dependencies changed: rebuild the image
path: ./api/package.json
web:
build: ./web
develop:
watch:
- action: sync # nginx serves the new files right away
path: ./web/public
target: /usr/share/nginx/html
- action: rebuild
path: ./web/nginx.conf
You learned this split in Compose Profiles, Override Files and Multiple Environments and the watch feature in Local Development with Compose.
Part 4: run it and test it
From the notes-app folder:
docker compose up --build -d
Watch the services start in order: db first, then api, then web.
docker compose ps
NAME IMAGE SERVICE STATUS PORTS
notes-app-api-1 ghcr.io/your-name/notes-api:dev api Up 25 seconds (healthy) 3000/tcp
notes-app-db-1 postgres:17-alpine db Up 31 seconds (healthy) 5432/tcp
notes-app-web-1 ghcr.io/your-name/notes-web:dev web Up 12 seconds (healthy) 127.0.0.1:8080->80/tcp
All three say (healthy). If one does not, jump to “Common mistakes” below, or use the method from Debugging Containers That Will Not Start.
Test through nginx, like the browser does
curl http://localhost:8080/api/health
ok
curl -X POST http://localhost:8080/api/notes \
-H 'content-type: application/json' \
-d '{"text":"Hello from the capstone"}'
{"id":1,"text":"Hello from the capstone","created_at":"2026-10-25T18:40:12.345Z"}
curl http://localhost:8080/api/notes
[{"id":1,"text":"Hello from the capstone","created_at":"2026-10-25T18:40:12.345Z"}]
Now open http://localhost:8080 in your browser. You see the page, the note you just added, and you can add more.
Test that the data survives
docker compose down # removes containers and networks, but keeps volumes
docker compose up -d
curl http://localhost:8080/api/notes
Your note is still there, because it lives in the pgdata volume, not in the container.
Warning:
docker compose down -valso deletes volumes. Locally, that is a handy way to start fresh. On a server, it deletes your data.
Test graceful shutdown
time docker compose stop api
docker compose logs api --tail 3
notes-app-api-1 | SIGTERM received, shutting down
The stop takes well under a second. If it took about 10 seconds, the app would not be receiving the signal, and Docker would have to kill it. Start it again with docker compose start api.
Test the network separation
# web cannot reach the database: they share no network
docker compose exec web wget -q -T 2 -O- http://db:5432 || echo "blocked, as planned"
wget: bad address 'db:5432'
blocked, as planned
Part 5: check quality and security
A few quick checks that a real team would do before shipping.
Image sizes
docker images --filter reference='ghcr.io/your-name/*'
REPOSITORY TAG IMAGE ID CREATED SIZE
ghcr.io/your-name/notes-api dev 3c4d5e6f7a8b 5 minutes ago 170MB
ghcr.io/your-name/notes-web dev 9a8b7c6d5e4f 5 minutes ago 52MB
Your numbers will differ a little, but both images should be small, thanks to Alpine bases and the multi-stage build. Compare with the full node:24 base, which alone is over 1 GB.
Who is the app running as?
docker compose exec api id
uid=1000(node) gid=1000(node) groups=1000(node)
Not root. Good.
Known vulnerabilities
docker scout quickview ghcr.io/your-name/notes-api:dev
docker scout quickview ghcr.io/your-name/notes-web:dev
Fix any critical or high CVEs that have a fix, usually by rebuilding with --pull. See Scanning Images for Vulnerabilities with Docker Scout.
Part 6: build and push with CI
Each service needs its own image in the registry. A GitHub Actions matrix runs the same job once per service. Put this in .github/workflows/images.yml:
name: images
on:
push:
branches: [main]
tags: ['v*']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service: [api, web] # one run for ./api, one for ./web
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/your-name/notes-${{ matrix.service }}
tags: |
type=sha
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: ./${{ matrix.service }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
The scope keeps the two services’ build caches apart. To release, push a Git tag:
git tag v1.0.0
git push origin v1.0.0
CI pushes ghcr.io/your-name/notes-api:1.0.0 and ghcr.io/your-name/notes-web:1.0.0. Every step here is explained in Building and Pushing Images in GitHub Actions.
Part 7: deploy to a server
Follow Deploying a Compose App to a Linux Server to prepare an Ubuntu server with Docker Engine and a firewall. Then the deploy itself is short, because compose.yaml was written for production from the start.
Copy only what the server needs
The server needs exactly two files. No source code, no override file:
# On your computer
ssh deploy@203.0.113.10 'mkdir -p /opt/notes-app'
scp compose.yaml deploy@203.0.113.10:/opt/notes-app/
Create the real .env on the server:
# On the server
cd /opt/notes-app
nano .env
chmod 600 .env
TAG=1.0.0
POSTGRES_PASSWORD=change-me-use-openssl-rand-hex-24
Pull and start
docker compose pull
docker compose up -d
docker compose ps
Because there is no compose.override.yaml on the server, there is no build: key, so Compose only pulls and runs the images CI built. The exact same images you tested.
Add HTTPS
The web service listens on 127.0.0.1:8080 only. Put the host nginx and certbot setup from the deployment lesson in front, with one change in the proxy line:
location / {
proxy_pass http://127.0.0.1:8080; # the notes-app web container
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Then run sudo certbot --nginx -d notes.example.com, and your app is live at https://notes.example.com.
Update and roll back
# Deploy 1.1.0
sed -i 's/^TAG=.*/TAG=1.1.0/' .env
docker compose pull
docker compose up -d
# Something wrong? Go back to 1.0.0
sed -i 's/^TAG=.*/TAG=1.0.0/' .env
docker compose up -d
Back up the notes
docker compose exec -T db pg_dump -U notes notes > notes-$(date +%F).sql
Run it every night with cron and copy the file off the server.
Common mistakes
nginx exits with “host not found in upstream”
nginx: [emerg] host not found in upstream "api" in /etc/nginx/conf.d/default.conf:17
nginx looks up api when it starts. If the api container does not exist yet, or is not on the same network, nginx stops. Check that web and api share the frontend network and that web has depends_on: api: condition: service_healthy.
502 Bad Gateway after recreating the api
nginx remembers the api’s IP address from when it started. If the api container is recreated and gets a new IP, nginx may still send requests to the old one. The quick fix is docker compose restart web. A permanent fix is to tell nginx to use Docker’s internal DNS server (resolver 127.0.0.11) and put the upstream address in a variable, which makes nginx look the name up again regularly.
404 from the api for every /api/ request
The trailing slash is missing in proxy_pass http://api:3000/;, so the api receives /api/notes instead of /notes.
The api restarts with “password authentication failed”
PostgreSQL only uses POSTGRES_PASSWORD the first time it creates the database in an empty volume. If you change the password in .env later, the database still has the old one. Locally, start fresh with docker compose down -v. On a server, change the password inside PostgreSQL with ALTER USER instead.
“required variable POSTGRES_PASSWORD is missing a value”
You have no .env file, or it is in a different folder. Run cp .env.example .env in the folder that holds compose.yaml.
Changes to the frontend do not appear
You are running without watch, and the files are baked into the image. Use docker compose up --watch, or rebuild with docker compose up --build -d. Also try a hard refresh in the browser, which skips its cache.
What you practiced
| Skill | Where in this project |
|---|---|
| Writing Dockerfiles | api/Dockerfile, web/Dockerfile |
| Layer caching, multi-stage, cache mounts | api/Dockerfile |
.dockerignore and build contexts |
api/.dockerignore, one folder per service |
| Non-root, read-only, dropped capabilities | api service |
| Volumes | pgdata |
| User-defined and internal networks | frontend and backend |
| Health checks and startup order | every service, depends_on |
Environment variables and .env |
TAG, POSTGRES_PASSWORD, DATABASE_URL |
| Override files and watch | compose.override.yaml |
| Log rotation | the x-logging anchor |
| Scanning | docker scout quickview |
| CI, tags and registries | .github/workflows/images.yml |
| Deployment, updates, rollback, backups | Part 7 |
Ideas to extend it
When this works, try one or more of these on your own:
- Add Redis (
redis:8-alpine, service namecache) on the backend network and cache theGET /notesresult for a few seconds. - Use a Compose secret file for the database password instead of an environment variable. The postgres image supports
POSTGRES_PASSWORD_FILE. - Add a
DELETE /notes/:idendpoint and a delete button. - Build both images for
linux/amd64andlinux/arm64with Multi-Platform Images with Buildx. - Add a CI step that fails on critical CVEs.
- Write the Kubernetes manifests for all three services, starting from From Docker to Kubernetes: What Comes Next.
Quick recap
- A full-stack app becomes three services:
web(nginx),api(Node.js) anddb(PostgreSQL), each with its own image. - Only
webpublishes a port; nginx serves the frontend and proxies/api/to the api by service name. - Two networks keep the web server away from the database; a named volume keeps the data.
- Health checks plus
depends_on: condition: service_healthystart everything in the right order. compose.yamldescribes production, andcompose.override.yamladds builds and watch for development.- CI builds and tags both images; the server only needs
compose.yamland.envto pull, run, update and roll back.
Congratulations, you have finished the Docker course! You can now take almost any app, put it in containers, and run it reliably from your laptop to a server. To keep going, explore the wider pipeline in the DevOps course, starting with What is DevOps?, or revisit the beginning with What is Docker and Why Use It? and see how much you have learned.
# compose.yaml: the notes-app capstone (nginx frontend, Node.js API, PostgreSQL)
# Local: cp .env.example .env, then docker compose up --build -d (compose.override.yaml adds build and watch)
# Server: copy only this file plus a real .env, then docker compose pull && docker compose up -d
# Open: http://localhost:8080
#
# .env holds: TAG=dev and POSTGRES_PASSWORD=devpassword (letters and numbers only)
name: notes-app
# Log rotation for every service: "&" creates the anchor, "*" reuses it
x-logging: &default-logging
driver: local
options:
max-size: "10m"
max-file: "3"
services:
# ---------- web: nginx serves the frontend and proxies /api/ to the api ----------
web:
image: ghcr.io/your-name/notes-web:${TAG:-dev}
# The only published port; 127.0.0.1 so on a server only the local reverse proxy reaches it
ports:
- "127.0.0.1:8080:80"
# Only on frontend: the web server can never reach the database
networks:
- frontend
# Start after the api is healthy (nginx needs the name "api" to exist)
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
logging: *default-logging
# ---------- api: the Node.js notes-api ----------
api:
image: ghcr.io/your-name/notes-api:${TAG:-dev}
environment:
PORT: "3000"
# "db" is the service name below; Docker DNS turns it into an IP address
DATABASE_URL: postgres://notes:${POSTGRES_PASSWORD}@db:5432/notes
# No ports: only nginx talks to it, over the frontend network
networks:
- frontend
- backend
# Wait until PostgreSQL really accepts connections
depends_on:
db:
condition: service_healthy
# Node's built-in fetch, because the Alpine Node image has no curl
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
# Hardening: read-only files, temp folder in memory, no capabilities, no escalation
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 256m
restart: unless-stopped
logging: *default-logging
# ---------- db: PostgreSQL stores the notes ----------
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_DB: notes
# Stop with a clear message if the password is missing from .env
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
# The data lives in a named volume and survives updates (docker compose down -v deletes it!)
volumes:
- pgdata:/var/lib/postgresql/data
# No ports and only on the internal backend network
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
logging: *default-logging
networks:
frontend:
backend:
# Containers only on this network get no internet access
internal: true
volumes:
pgdata:
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready