Why this matters
Backend Engineers ship services that must run the same way on laptops, CI, and production. Containers provide that consistency and make CI/CD pipelines faster and safer. You will use containers to: package services, run tests in isolated environments, tag versions for releases, push images to registries, and deploy to orchestrators.
- Real tasks you will do: build Docker images in CI, scan for vulnerabilities, push to a registry, run integration tests with dependent services via Compose, and set health checks for production.
Who this is for
- Backend Engineers and Platform/DevOps beginners who need a reliable way to run services across environments.
- Anyone integrating applications into CI/CD pipelines.
Prerequisites
- Basic CLI skills
- Ability to run local commands with Docker or another OCI-compatible runtime
- Basic knowledge of your service runtime (e.g., Python, Node.js, Go)
Concept explained simply
A container is a lightweight, isolated process with everything it needs to run: code, runtime, and dependencies. An image is the read-only template used to start containers. You build an image from a Dockerfile (or similar) and run containers from that image.
Mental model
Think of an image as a frozen recipe card with all ingredients and instructions. A container is the dish cooked from that recipe. Layers in the image are like pre-prepped ingredients; if they don’t change, the kitchen reuses them to cook faster next time (build cache).
Key terms you should know
- Image: Read-only template for containers. Built from layers.
- Container: A running instance of an image.
- Tag: A label for an image version (e.g., 1.2.0, latest).
- Registry: Remote store for images (e.g., company registry).
- Build context: Files sent to the builder. Controlled by .dockerignore.
- Layer: A cached snapshot of filesystem changes.
- Entrypoint/CMD: What runs when the container starts.
- Port mapping: Expose container ports to the host (e.g., -p 8080:80).
- Volume/bind mount: Persist or share data/config between host and container.
- Healthcheck: A command to report if the container is healthy.
Worked examples
Example 1 — Minimal Flask service (Python)
# app.py
from flask import Flask
import os
app = Flask(__name__)
@app.get("/")
def hi():
return {"msg": os.getenv("GREETING", "Hello" )}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
# requirements.txt
flask==3.0.0
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV GREETING=Hello-from-container
EXPOSE 5000
CMD ["python", "app.py"]
Build and run:
docker build -t flask-demo:1.0 .
docker run --rm -p 8080:5000 -e GREETING=Hi flask-demo:1.0
# curl http://localhost:8080 -> {"msg":"Hi"}
Example 2 — Production-focused Node.js (install only prod deps)
# server.js
const express = require('express');
const app = express();
app.get('/', (_, res) => res.json({msg: process.env.GREETING || 'Hello'}));
app.listen(3000, '0.0.0.0');
# package.json
{
"name": "node-demo",
"version": "1.0.0",
"main": "server.js",
"type": "module",
"dependencies": {"express": "^4.19.2"}
}
# Dockerfile
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:18-alpine
WORKDIR /app
ENV NODE_ENV=production GREETING=Hello-from-node
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Benefit: smaller runtime image, faster cold starts and fewer dev-only packages in production.
Example 3 — Go static binary with multi-stage
# main.go
package main
import (
"fmt"; "net/http"; "os"
)
func main(){
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request){
fmt.Fprintf(w, "{\"msg\":\"%s\"}", os.Getenv("GREETING"))
})
http.ListenAndServe(":8080", nil)
}
# Dockerfile
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app
FROM scratch
ENV GREETING=Hello-from-go
EXPOSE 8080
COPY --from=build /src/app /app
ENTRYPOINT ["/app"]
Result: very small, minimal attack surface. Ensure your binary is static (CGO_ENABLED=0).
Step-by-step: Dockerize a service
- Choose a base image that matches your runtime (e.g., python:3.11-slim). Prefer slim or alpine variants if compatible.
- Set a working directory (WORKDIR /app) and copy only what you need early (requirements files) to leverage cache.
- Install dependencies using no-cache flags where possible.
- Copy application code after dependencies to avoid busting cache on every change.
- Configure runtime: environment variables, EXPOSE, HEALTHCHECK, and a clear CMD/ENTRYPOINT.
- Run the container with port mappings and env vars. Test with curl.
- Tag and push to your registry when ready (e.g., myorg/service:1.0.0).
What goes into .dockerignore?
- venv/, node_modules/, build/
- .git/, .idea/, .vscode/
- *.log, *.tmp
- .env, secrets, local caches
Common mistakes and self-check
- Sending huge build contexts: Add a proper .dockerignore. Self-check: run docker build with --progress=plain and ensure small context size.
- Hardcoding secrets: Do not bake secrets into images. Use environment variables or secret managers.
- Running as root: Create a non-root user. Self-check: docker exec whoami shows your app user.
- Unpinned dependencies: Pin versions for reproducible builds.
- No healthcheck: Add HEALTHCHECK to detect broken containers.
- Exposing wrong ports: Ensure EXPOSE matches your app and -p mappings are correct.
- Layer cache misses: Copy dependency files before source code so small code changes don’t reinstall all deps.
Quick self-audit commands
# See image layers
docker history <image:tag>
# Inspect metadata (env, exposed ports, user)
docker inspect <image:tag>
# Check health status
docker ps --format 'table {{.Names}}\t{{.Status}}'
Exercises
Mirror of the interactive exercises below. Do them locally and validate with the expected outputs.
Exercise 1 — Create and run your first containerized web service
- Create files: app.py, requirements.txt, Dockerfile (use Python example above).
- Build image: docker build -t hello-flask:1.0 .
- Run: docker run --rm -p 8080:5000 -e GREETING=LuvvHelp hello-flask:1.0
- Test: curl http://localhost:8080 should return your greeting in JSON.
- Checklist:
- Image builds without errors
- Container responds on port 8080
- Changing GREETING changes the response
Exercise 2 — Optimize and harden your image
- Add a .dockerignore that excludes venv/, .git/, node_modules/, *.log, .env.
- Modify Dockerfile: use python:3.11-slim, add a non-root user, use pip --no-cache-dir, and add a HEALTHCHECK.
- Rebuild with a new tag: hello-flask:1.1
- Run and verify whoami is not root and container becomes healthy.
- Checklist:
- Image size is smaller than initial build
- whoami prints your non-root user
- docker ps shows (healthy) after a short time
Practical projects
- Containerize a small CRUD API (any language) with a database sidecar using Compose. Add healthchecks for both.
- Create a CI job that builds, tags (commit SHA), and pushes your image to a registry mirror or local registry.
- Implement multi-stage builds that run unit tests in the builder stage and copy only the final artifacts to the runtime stage.
Learning path
- Start: Single-service containerization (this lesson)
- Next: Docker Compose for local multi-service development
- Then: Image scanning and SBOM in CI
- Finally: Deploy to an orchestrator (Kubernetes basics)
Next steps
- Refactor one of your existing services to run as non-root with a healthcheck.
- Add a proper .dockerignore to all your repos.
- Adopt consistent image tagging: app:1.2.0, app:1, app:latest, app:commit-sha.
Mini challenge
Ship a lean image
- Turn any of the worked examples into a multi-stage build.
- Target: cut image size by at least 30%.
- Prove it: show docker images before and after, and that the service still passes a curl smoke test.
About the quick test and saving progress
The quick test is available to everyone. If you are logged in, your progress is saved automatically.