Your First Containerised App
The capstone: take a small application from bare source to a built, configured, published image running behind Compose with persistent data — every step from the earlier lessons in one pass.
The capstone: take a small application from bare source to a built, configured, published image running behind Compose with persistent data — every step from the earlier lessons in one pass.
Twelve lessons of pieces. This one puts them together, in the order you would actually do it, on an application that needs all of them: a small web service with a database and a cache, configured from the environment, built as a small non-root image, running behind Compose with data that survives, and published so someone else can run it.
Follow it end to end on your own machine. By the finish you will
have a project you could hand to a colleague with a single
instruction — docker compose up — and a checklist you can apply to
anything you containerise afterwards.
Write the .dockerignore first
Before the Dockerfile, so no secret and no cache is ever in a build context — not even once.
Write the Dockerfile
Multi-stage, non-root, health-checked. The destination from the earlier lessons, not a first draft.
Describe the whole system in compose.yaml
Database, cache, volumes, health conditions, configuration
from .env.
Run it, then destroy it and run it again
The step people skip. It is the only proof your data lives outside the container.
Add a development override
Bind mount and a reloading server, kept out of the base file so the base stays deployable.
Check the image, then publish it
Two ten-second checks for secrets, then tag with a real version and push.
A note-taking API: it stores notes in Postgres, counts views in Redis, and reports its own health. Small enough to read in one sitting and real enough to exercise everything.
Make a directory and create app.py:
And requirements.txt:
Two things to notice, both deliberate. The two URLs are read with
bracket access, so the process refuses to start without them rather
than failing on its first request. And there is a /health
endpoint that actually touches both dependencies — a health check
that only proves the web framework is awake is worth very little.
Before writing the Dockerfile, write the .dockerignore. Doing it
first means no secret and no cache is ever in a build context, not
even once:
This is the multi-stage, non-root, health-checked version — the destination from the earlier lessons rather than a first draft:
Every line is one of the earlier lessons. Dependencies are copied
and installed before the source, so the cache survives a code
change. The virtual environment is built in a stage that is thrown
away. PYTHONUNBUFFERED=1 keeps docker logs honest. The process
runs as appuser. The bind address is 0.0.0.0, not 127.0.0.1.
And the command is gunicorn in the bracket form, so it is process 1
and hears docker stop.
Build it and check what you got:
compose.yaml, with the database and cache alongside:
And the two files that go with it — one committed, one not:
The :?set it suffix means Compose refuses to start with a clear
message rather than silently building a database URL with an empty
password. Neither database nor cache publishes a port: the API
reaches them at db:5432 and cache:6379 over the project network,
and nothing outside needs to.
Every service should read healthy. Then exercise it:
Now the test that matters, and the one people skip:
If the note survived, your storage is genuinely outside the container. If it did not, the volume is misconfigured — and finding that out now is the entire reason to try it.
For working on the code you want edits to take effect immediately.
Compose reads compose.override.yaml automatically, so development
differences live there and the base file stays deployable:
Edit app.py, save, and hit the endpoint again — no rebuild.
Base plus override — how you work.
Your source is bind-mounted, the development server reloads on save, and debug output is on.
Compose picks up compose.override.yaml without being asked.
Base only — what actually deploys.
Naming the file explicitly stops the override being applied.
This is the configuration a server runs, so it is worth running yourself before you claim it works.
Check the image before it becomes public, then tag and push:
Then prove the push, from an empty local cache:
If your target servers are amd64 and you built on Apple silicon,
build for both:
Apply this to anything you containerise from here on. Each line is one lesson from this course.
You can now containerise an application properly: build it small, run it unprivileged, configure it from outside, keep its data, wire its services together, diagnose it when it fails, and publish it for someone else to run. That is the whole of Docker that most work needs.
The natural next step is what happens when one machine is not enough. Compose starts your services on the host you are sitting at; it cannot move a container when a machine dies, spread copies across several hosts, or roll out a new version without dropping requests. Kubernetes is the answer to those questions, and everything you have learned here is its input — a container is still a container, and the image you just published is exactly what a cluster runs.
Before that, do the most useful exercise available: containerise something of your own, working down the checklist above. The first time you do it without this lesson open, it will have become a skill.
flask==3.0.3
psycopg[binary]==3.2.3
redis==5.2.0
gunicorn==23.0.0.git
.gitignore
__pycache__
*.pyc
.venv
venv
.pytest_cache
.coverage
*.log
.env
.env.*
*.pem
*.key
Dockerfile
.dockerignore
compose*.yaml
README.md# .env.example — committed, so the next person knows
DB_PASSWORD=change-me-locally# .env — never committed
DB_PASSWORD=devpassImage
[ ] .dockerignore excludes .git, caches, .env, keys
[ ] base image pinned to a minor version, -slim or smaller
[ ] dependency manifest COPYed before the source code
[ ] multi-stage: build tools do not reach the final image
[ ] runs as a non-root USER
[ ] CMD in bracket form, so the process gets SIGTERM
[ ] server binds 0.0.0.0, not 127.0.0.1
[ ] no secret in any ENV, ARG or COPYed file
[ ] HEALTHCHECK that touches real dependencies
Compose
[ ] every stateful service has a named volume
[ ] depends_on uses condition: service_healthy
[ ] only the ports you need from your machine are published
[ ] configuration from .env, with a committed .env.example
[ ] restart: unless-stopped on long-running services
Before publishing
[ ] docker history --no-trunc shows no credentials
[ ] the image runs after deleting the local copy
[ ] tagged with a real version, not only :latest
[ ] built for the architecture the target actually runsimport os
import psycopg
import redis
from flask import Flask, jsonify, request
app = Flask(__name__)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]
cache = redis.Redis.from_url(REDIS_URL, decode_responses=True)
def setup_database():
with psycopg.connect(DATABASE_URL) as connection:
connection.execute(
"CREATE TABLE IF NOT EXISTS notes ("
" id serial PRIMARY KEY,"
" body text NOT NULL,"
" created_at timestamptz DEFAULT now())"
)
@app.get("/health")
def health():
with psycopg.connect(DATABASE_URL) as connection:
connection.execute("SELECT 1")
cache.ping()
return jsonify(status="ok")
@app.post("/notes")
def create_note():
body = request.get_json(force=True)["body"]
with psycopg.connect(DATABASE_URL) as connection:
row = connection.execute(
"INSERT INTO notes (body) VALUES (%s) RETURNING id",
(body,),
).fetchone()
return jsonify(id=row[0], body=body), 201
@app.get("/notes")
def list_notes():
views = cache.incr("notes:views")
with psycopg.connect(DATABASE_URL) as connection:
rows = connection.execute(
"SELECT id, body FROM notes ORDER BY id DESC LIMIT 50"
).fetchall()
notes = [{"id": row[0], "body": row[1]} for row in rows]
return jsonify(notes=notes, views=views)
setup_database()# ---- build ----
FROM python:3.12-slim AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- run ----
FROM python:3.12-slim
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PORT=8000
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY app.py .
RUN useradd --create-home --shell /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD python -c "import urllib.request as u; \
u.urlopen('http://localhost:8000/health')"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]docker build -t notes-api:0.1.0 .
docker images notes-api
docker history notes-api:0.1.0services:
api:
build: .
image: notes-api:0.1.0
ports:
- '8000:8000'
environment:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD:?set it}@db:5432/notes
REDIS_URL: redis://cache:6379/0
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD:?set it}
POSTGRES_DB: notes
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d notes']
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
cache:
image: redis:7-alpine
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
retries: 10
restart: unless-stopped
volumes:
pgdata:cp .env.example .env # then edit the password
docker compose up -d
docker compose pscurl -s localhost:8000/health
# {"status":"ok"}
curl -s -X POST localhost:8000/notes \
-H 'Content-Type: application/json' \
-d '{"body":"containers are just processes"}'
# {"body":"containers are just processes","id":1}
curl -s localhost:8000/notes
# {"notes":[{"body":"containers are just...","id":1}],"views":1}docker compose down # containers gone, volume kept
docker compose up -d
curl -s localhost:8000/notes # the note is still thereservices:
api:
build: .
volumes:
- .:/app
environment:
FLASK_DEBUG: '1'
command: flask --app app run --host 0.0.0.0 --port 8000docker history --no-trunc notes-api:0.1.0 | grep -i -E 'key|token|pass'
docker run --rm --entrypoint sh notes-api:0.1.0 -c "ls -la /app"
docker tag notes-api:0.1.0 myname/notes-api:0.1.0
docker tag notes-api:0.1.0 myname/notes-api:latest
docker login -u myname
docker push myname/notes-api:0.1.0
docker push myname/notes-api:latestdocker rmi notes-api:0.1.0 myname/notes-api:0.1.0
docker compose -f compose.yaml up -d # pulls what it needsdocker buildx build --platform linux/amd64,linux/arm64 \
-t myname/notes-api:0.1.0 --push .# The whole loop, in order
docker build -t notes-api:0.1.0 . # 1. build
docker images notes-api # 2. check the size
docker history notes-api:0.1.0 # 3. check the layers
docker compose up -d # 4. run the system
docker compose ps # 5. confirm healthy
curl -s localhost:8000/health # 6. exercise it
docker compose down && docker compose up -d # 7. data survives?
docker compose logs -f api # 8. watch it work
# Development versus deployed
docker compose up # base + override
docker compose -f compose.yaml up -d # base only
docker compose up --build # after a code change
docker compose config # what Compose resolved
# Publishing
docker tag notes-api:0.1.0 myname/notes-api:0.1.0
docker login -u myname
docker push myname/notes-api:0.1.0
docker buildx build --platform linux/amd64,linux/arm64 \
-t myname/notes-api:0.1.0 --push .
# When something is wrong
docker compose ps # what state is each service
docker compose logs api # what did it print
docker compose exec api sh # look inside
docker run -it --rm --entrypoint sh notes-api:0.1.0 # image only