Closing the series with a recap of the episode 0-26 journey mapped to real-world scenarios, then an end-to-end walkthrough deploying a full-stack application (web, API, database, cache, Traefik with TLS, and monitoring) plus a roadmap toward Kubernetes, GitOps, and Docker certification.

Congratulations, you've made it to episode 27 — the final episode of the Learn Docker series. The journey that started with the question "why do we need containers?" now ends with the ability to deploy a complete application to production. Before this episode, in episode 26 we closed out the advanced security side: secrets, Rootless Docker, and supply chain security. Now it's time for two things: summarizing the entire journey so you can see the thread running through every episode, and applying it all in one complete hands-on project.
This isn't a theory episode. We'll build and deploy a full-stack application — web frontend, backend API, database, cache, reverse proxy with automatic TLS, and monitoring — using everything we've learned: multi-stage builds, production Compose, healthchecks, resource limits, networks, volumes, and Traefik labels. If you follow every step, by the end of this episode you'll have a stack that looks exactly like what many startups run: one server, one compose.prod.yaml, and everything running on top of Docker.
Before writing any code, let's look at the complete map of where we've been. Every phase in this series answers one real question from the working world:
| Phase | Episodes | Key concepts | Real-world scenario |
|---|---|---|---|
| Foundations | 0-2 | Setup, history, client-server architecture | Why "works on my machine" happens & how Docker solves it |
| Basic operations | 3-4 | docker run, lifecycle, docker stats | Running & operating applications on a server |
| Images | 5-7 | Dockerfile, layer caching, multi-stage | Building images that are small, fast, and secure |
| Storage & Network | 8-9 | Volume, bind mount, network driver | Databases whose data persists & containers that talk to each other |
| Compose | 10-11 | Multi-container, env, profiles, anchors | Managing an entire application from one declarative file |
| Registry & Security | 12-14 | Push/pull, hardening, scanning, Cosign | Distributing images & making sure they're production-ready |
| Observability | 15 | Logging, healthcheck, Prometheus | Knowing when an app is sick before users complain |
| Orchestration | 16-17 | Swarm, service, secrets, rolling update | Managing many hosts and updating without downtime |
| Build & CI/CD | 18-19 | Buildx, BuildKit, GitHub Actions | Building, scanning, and publishing images automatically |
| Production | 20-23 | Traefik, dev workflow, case studies, image internals | Connecting apps to the internet with automatic TLS |
| Operations | 24-26 | daemon.json, resource limits, security advanced | Keeping hosts healthy and secrets secret |
Notice the pattern: every previous episode is material you're now assembling. In this project, let's use all of it.
Our project is called "Catat": a simple notes application — web frontend (Next.js), API (Node.js), PostgreSQL database, Redis cache, Traefik reverse proxy, and Prometheus + Grafana monitoring. The learning goal here isn't the application itself (it's simple), but the architecture and all the deployment details that come with it.
catat/
├── compose.prod.yaml
├── .env.production
├── web/ # Frontend Next.js
│ ├── Dockerfile
│ └── next.config.mjs # output: 'standalone'
├── api/ # Backend Node.js
│ ├── Dockerfile
│ └── server.js
└── monitoring/
└── prometheus.ymlThe rule we hold from previous episodes: one service = one directory with a multi-stage Dockerfile, and one compose file as the single source of truth for the production environment.
The frontend uses the three-stage multi-stage pattern from episode 7: deps (install dependencies), builder (production build), runner (slim runtime). The key to getting Next.js running in a slim container is output: 'standalone' in next.config.mjs — Next.js produces a minimal server containing only the files it needs:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG API_URL
ENV NEXT_PUBLIC_API_URL=$API_URL
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]Notice three things that come from episodes 6 and 13: USER nextjs (non-root), ARG API_URL injected at build time to determine the API URL the browser reaches, and a final stage that carries no source code — only the build output and the node_modules that are genuinely required.
The API uses a more compact pattern: one dependency stage, one runtime stage. No builder stage is needed because a plain Node.js application runs directly; the key is that only production dependencies are copied:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=node:node . .
RUN addgroup -g 1001 -S app \
&& adduser -S app -G app \
&& chown -R app:app /app
USER app
EXPOSE 4000
CMD ["node", "server.js"]npm ci --omit=dev is a small detail with a big impact: the production image carries no development tooling — smaller, fewer CVEs, and faster to pull. This is a practice you know from episodes 7 and 26 (minimal base images).
This is the most important file in the project. It ties all the services together with the principles from episodes 10, 11, 15, 20, and 25: healthchecks to drive depends_on, resource limits so there's no noisy neighbor, networks for isolation, volumes for persistent data, and Traefik labels for routing + automatic TLS:
name: catat
networks:
web:
backend:
monitoring:
volumes:
postgres-data:
redis-data:
traefik-certs:
grafana-data:
prometheus-data:
services:
traefik:
image: traefik:v3.1
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=admin@example.com
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
networks:
- web
web:
build:
context: ./web
args:
API_URL: https://api.catat.example
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000"]
interval: 30s
timeout: 5s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`catat.example`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=letsencrypt"
- "traefik.http.services.web.loadbalancer.server.port=3000"
networks:
- web
- backend
depends_on:
api:
condition: service_healthy
mem_limit: 512m
cpus: 1
api:
build: ./api
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@postgres:5432/app
REDIS_URL: redis://redis:6379
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.catat.example`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
- "traefik.http.services.api.loadbalancer.server.port=4000"
networks:
- backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
mem_limit: 512m
cpus: 1
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
mem_limit: 1g
cpus: 2
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
mem_limit: 512m
prometheus:
image: prom/prometheus:v2.53.0
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=15d
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- backend
- monitoring
mem_limit: 512m
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
volumes:
- grafana-data:/var/lib/grafana
labels:
- "traefik.enable=true"
- "traefik.http.routers.grafana.rule=Host(`monitor.catat.example`)"
- "traefik.http.routers.grafana.entrypoints=websecure"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
networks:
- web
- monitoring
mem_limit: 512mThings to notice in this file — all of them results from previous episodes:
depends_on with condition: service_healthy — the API only starts after Postgres and Redis are truly ready, not just because their containers are up (episode 10).pg_isready, redis-cli ping, and HTTP checks for web/API. This is the source of truth Traefik and Compose rely on (episode 15).mem_limit/cpus — the database gets the largest share; the cache is limited so it can't be wasteful; each following the lesson from episode 25.web for Traefik + anything that needs to be public, backend for internal communication, monitoring for telemetry. Postgres and Redis aren't visible from the internet at all (episode 9).${POSTGRES_PASSWORD} from .env.production — not hardcoded into the file (episodes 11 and 26). This .env.production file must go into .gitignore and must never be committed.Traefik configuration in this project is purely through labels — as you learned in episode 20. The Docker provider reads labels from each container, so routing, TLS, and service discovery become automatic: add a new service → add a label → Traefik starts serving immediately without a restart. The four core labels used across all services:
traefik.enable=true → service ini dipublikasikan
traefik.http.routers.<nama>.rule=Host(`domain`) → hostname yang dilayani
traefik.http.routers.<nama>.entrypoints=websecure → masuk lewat port 443
traefik.http.routers.<nama>.tls.certresolver=letsencrypt → minta sertifikat otomatis--certificatesresolvers.letsencrypt.acme.tlschallenge=true makes Traefik automatically request and renew Let's Encrypt certificates for every published hostname — without manual intervention and at no cost. This is a concrete example of how Docker + Traefik automate what used to take hours of manual work.
Once all the files are ready, here's the deployment sequence from start to verification. Run it on a server with Docker installed (episode 0 material) and a domain already pointed at the server's IP:
cp .env.example .env.production
docker compose -f compose.prod.yaml config --quietThis order is deliberate: validate before deploying (config --quiet ensures the YAML is valid), deploy the whole stack, then verify from the outside with curl — not just "the container is alive". Step 4 is the safe update practice: --no-deps rebuilds only the web service without touching the database, so downtime is nearly zero when you release a new version.
NAME IMAGE STATUS
catat-api-1 catat-api Up 2 minutes (healthy)
catat-web-1 catat-web Up 2 minutes (healthy)
catat-postgres-1 postgres:16-alpine Up 2 minutes (healthy)
catat-redis-1 redis:7-alpine Up 2 minutes (healthy)
catat-traefik-1 traefik:v3.1 Up 2 minutes
catat-grafana-1 grafana/grafana:11.1.0 Up 2 minutes (healthy)
catat-prometheus-1 prom/prometheus:v2.53.0 Up 2 minutesThe (healthy) status on each service is proof that the healthchecks are working — and this is exactly what Traefik checks before sending traffic, and what you'll rely on during automation (episode 15).
Before declaring yourself "production-ready", run through the following checklist — a summary of the entire series:
USER in every image, cap-drop/no-new-privileges on sensitive containers, secrets never committed and never in plain env (episodes 6, 13, 26).mem_limit/cpus on every service — no container without a fence (episode 25).restart: unless-stopped, volumes for all persistent data (episodes 8, 10, 15).docker system df + controlled prune on the host (episode 24).If all these points are met, your application already follows the same patterns industry DevOps teams hold to.
This series ends, but your journey is just beginning. Docker is the foundation of a much larger ecosystem, and two big goals await:
Learn Kubernetes — if Docker is "one server, many containers", Kubernetes is "many servers, one platform". You'll learn multi-host orchestration, auto-scaling, self-healing (dead containers get replaced automatically), and the concepts of Pod, Deployment, and Service that organize containers at data-center scale. Every Docker concept you've mastered — images, volumes, networks, resource limits, healthchecks — is the foundation that makes learning Kubernetes far easier.
GitOps (Argo CD / Flux) — a paradigm where the git repository becomes the single source of truth for infrastructure state. Deployments happen not by typing commands, but through a pull request: changes in git are automatically synced to the cluster by operators like Argo CD or Flux. This is the evolution from the manual compose.prod.yaml toward deployments that are auditable and easy to roll back.
Don't jump ahead. The right order: master Docker on a single host (done), then expand with Swarm to understand basic orchestration (episodes 16-17), then make the leap to Kubernetes.
To deepen and legitimize your skills:
docs.docker.com) — the most authoritative reference for docker run, the Compose spec, daemon.json, and new features.docker/awesome-compose to CI templates in the GitHub Actions documentation; code is the best teacher once you understand the concepts.In this episode 27 you've closed the series with two things: a recap — seeing how 27 episodes form one continuous arc from setup, operations, images, storage, networking, compose, security, observability, orchestration, CI/CD, to production; and an application — deploying the full-stack "Catat" application with a Next.js frontend and Node.js API (multi-stage Dockerfiles), PostgreSQL, Redis, a Traefik reverse proxy with automatic TLS, Prometheus, and Grafana, all from a single compose.prod.yaml complete with healthchecks, resource limits, networks, and volumes.
Core takeaways from the entire series:
Twenty-seven episodes, from "It works on my machine" to "deployed with one command". You no longer just use Docker — you understand Docker. Now it's time to build, solve problems, and keep learning. Congratulations, and see you in the Learn Kubernetes series — where your container adventure continues at a larger scale. Keep the momentum going!