Project: A Containerized Microservice
Package a health-checked Python service into a small reproducible image, compose it with Postgres, and publish an immutable tag from CI.
By the end, you’ll be able to…
- Define the runtime contract: configuration, port, health, and shutdown.
- Write a multi-stage Dockerfile that runs as a non-root user.
- Compose the service with a database that reports readiness.
- Publish an immutable image and document a rollback path.
01Define the runtime contract
A container runs one process with an explicit configuration boundary. Read the port, database URL, and version from environment variables, never from a committed settings file. Log to stdout so the platform can collect output, and expose a health endpoint that reports whether the process can serve traffic rather than whether it merely started.
The service should start quickly, fail loudly on invalid configuration, and shut down gracefully. A health check that always returns ok is worse than none because it teaches the orchestrator to send traffic to a broken process. Keep the check cheap and make it depend on the resources the request path actually needs.
import osfrom fastapi import FastAPIapp = FastAPI()@app.get("/health")def health() -> dict[str, str]: return {"status": "ok", "version": os.environ.get("APP_VERSION", "dev")}02Build a small, reproducible image
A multi-stage build installs dependencies in a builder stage and copies only the installed environment into the runtime stage. Pin the base image by tag or digest, copy requirements before application code so the dependency layer caches, and exclude .git, tests, virtual environments, and secrets with .dockerignore.
Run as a non-root user and set PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED for predictable logs. Do not put tokens in ARG or ENV: image layers are inspectable. Tag releases with a version or commit SHA, not only latest, so a deployment can name exactly what it runs.
# syntax=docker/dockerfile:1FROM python:3.12-slim AS builderWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir --prefix=/install -r requirements.txtFROM python:3.12-slimENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1WORKDIR /appCOPY --from=builder /install /usr/localCOPY app.py .RUN useradd --create-home appuserUSER appuserEXPOSE 8000CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]03Compose the service with its database
Compose is a local topology description, not a production orchestrator. It is still the fastest way to prove that the image works with a real database. Give each service a name, pass configuration through the environment, and publish only the port a developer needs. In the example, Compose reads POSTGRES_PASSWORD from the environment or a local .env file; the value is never committed.
depends_on controls start order but does not prove readiness. Add a database healthcheck and use condition: service_healthy so the API does not race an unready Postgres. A named volume preserves data across container recreation; bind mounts are for source during development, not for production data.
services: api: build: . environment: DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app APP_VERSION: local ports: - "8000:8000" depends_on: db: condition: service_healthy db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: app volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d app"] interval: 5s timeout: 3s retries: 5volumes: pgdata:04Publish immutable tags and plan the rollback
CI should build the same Dockerfile developers use, run the tests, and push an image tagged with the commit SHA. Authenticate with the platform's short-lived token, not a long-lived password in the repository. Upload the image digest with the deployment record so the running artifact is identifiable.
A rollback is a procedure, not a hope. Name the previous digest, the command that redeploys it, and the person or signal that triggers the decision. Database migrations complicate rollback: prefer backward-compatible migrations that can run before the new image and remain valid if the old image returns.
name: imageon: push: branches: [main]jobs: publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}The ideas worth keeping.
- Configuration comes from the environment; health must mean the service can work.
- A multi-stage, non-root image is smaller and safer to run.
- Immutable tags and a rehearsed rollback make deployment reversible.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Add a database readiness check
Extend the health endpoint so it returns status 'degraded' with HTTP 503 when DATABASE_URL is missing, and 'ok' otherwise. Keep the response JSON shape stable.
Explore a worked solution
import osfrom fastapi import FastAPIfrom fastapi.responses import JSONResponseapp = FastAPI()@app.get("/health")def health(): if not os.environ.get("DATABASE_URL"): return JSONResponse({"status": "degraded"}, status_code=503) return {"status": "ok"}print("health contract ready")The endpoint now reports a dependency the service needs. A real check should also verify that the database accepts a query, with a short timeout so the health check cannot hang.
Write the rollback checklist
Produce a five-step rollback procedure for a service deployed by image digest, including the migration constraint and the verification step.
Explore a worked solution
# Emergency rollback procedure1. Declare the incident and freeze further deploys.2. Record the failing image digest and the last known good digest.3. Confirm the previous image is compatible with the applied migrations.4. Redeploy the previous digest and wait for the health check to pass.5. Verify the user-facing path and preserve logs for the postmortem.The checklist names the artifact, the compatibility risk, and the evidence of recovery. A rollback that skips migration compatibility can turn one incident into two.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Add a database readiness check
Extend the health endpoint so it returns status 'degraded' with HTTP 503 when DATABASE_URL is missing, and 'ok' otherwise. Keep the response JSON shape stable.
health contract ready
Keep exploring.
The official references behind this lesson, when you want a second angle or the full details.
Pause. Put the pieces together.
You’ve reached the end of Deployment and DevOps. Before moving on, use this task to check what you can do independently.
Create a Dockerfile for your application, build and run the container, and set up a GitHub Actions workflow that tests and deploys on push to main.
Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.
Review this moduleOne lesson closer.
Reading is a start. Trying it yourself makes the connection.