DevOps & Cloud Topic

Containers and Docker: A Simple Guide with a Working App

You write an app on your laptop. It works there, but fails on another computer. One reason may be a different Python version or a missing file.

A container image packages your app and the files it needs. A container is a running copy of that image. This makes the setup easier to repeat. It does not remove every difference between computers.

What you will build

You will package a small Python web app. It has a home page, a health check and basic metrics. It uses no cloud account and no database. This is a learning server, not a complete production web service.

Know these five words

  • Image: The package used to start a container.
  • Container: A running process made from an image.
  • Dockerfile: The steps used to build the image.
  • Registry: A place that stores images for download.
  • Volume: Storage that can live beyond one container.

A container normally shares its host’s kernel. A virtual machine has its own guest operating system. On a Mac or Windows computer, Docker may run Linux containers inside a small virtual machine.

1. Make the demo app

Install Python 3.11 or newer. Make an empty folder called lecturre-devops-lab. Save this file as app.py:

"""Small local teaching server. Use a production app server for real traffic."""
import json
import os
import threading
import time
import uuid
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit

COUNTS = Counter()
LOCK = threading.Lock()
DEMO_DATA = {
    "products": [{"id": 1, "name": "Notebook", "price": 5}],
    "orders": [{"id": 101, "user_id": 7, "product_id": 1, "quantity": 2}],
    "users": [{"id": 7, "name": "Demo User"}],
}


def payload(path, role="web", version="1.0"):
    if path == "/health":
        return 200, {"status": "ok"}
    if path == "/fail":
        return 500, {"error": "Simulated failure for the local lab"}
    if path == "/":
        data = {"service": role, "version": version}
        if role in DEMO_DATA:
            data["items"] = DEMO_DATA[role]
        return 200, data
    return 404, {"error": "Not found"}


def metrics_text():
    with LOCK:
        snapshot = dict(COUNTS)
    lines = ["# HELP demo_http_requests_total Demo HTTP requests.",
             "# TYPE demo_http_requests_total counter"]
    for status in (200, 404, 500):
        lines.append(f'demo_http_requests_total{{status="{status}"}} {snapshot.get(status, 0)}')
    return "\n".join(lines) + "\n"


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        started = time.monotonic()
        request_id = str(uuid.uuid4())
        path = urlsplit(self.path).path
        if path == "/metrics":
            status, body, content_type = 200, metrics_text().encode(), "text/plain; version=0.0.4"
        else:
            status, data = payload(path, os.getenv("APP_ROLE", "web"), os.getenv("APP_VERSION", "1.0"))
            body, content_type = json.dumps(data).encode(), "application/json"
            with LOCK:
                COUNTS[status] += 1
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("X-Request-ID", request_id)
        self.end_headers()
        self.wfile.write(body)
        print(json.dumps({"request_id": request_id, "path": path, "status": status,
                          "duration_ms": round((time.monotonic() - started) * 1000, 2)}), flush=True)

    def log_message(self, format, *args):
        pass  # We emit one structured log above instead.


if __name__ == "__main__":
    host = os.getenv("HOST", "127.0.0.1")
    port = int(os.getenv("PORT", "8080"))
    ThreadingHTTPServer((host, port), Handler).serve_forever()

Run python app.py. Open http://127.0.0.1:8080/health. You should see {"status": "ok"}. Stop it with Ctrl+C before moving on.

The server is small so you can read its whole code. Real public traffic needs a suitable application server, access rules and operational care.

2. Add a Dockerfile

Install Docker using its official instructions, then make sure its engine is running. Add this Dockerfile:

FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOST=0.0.0.0 PORT=8080
COPY --chown=10001:10001 app.py ./
USER 10001:10001
EXPOSE 8080
CMD ["python", "app.py"]

FROM selects the base image. COPY adds the app. USER runs it as a user without root access. CMD starts it.

EXPOSE records the app’s port. It does not publish the port on your laptop. The run command below does that.

Add .dockerignore so local secrets and temporary files do not enter the build context:

.git
.env
.venv
__pycache__
*.pyc
*.tfstate*
*.tfplan
sbom-out

3. Build and run

docker build -t lecturre-web:1.0 .
docker run --rm --name lecturre-demo --read-only --tmpfs /tmp \
  --memory 128m --cpus 0.5 -p 127.0.0.1:8085:8080 lecturre-web:1.0

Open http://127.0.0.1:8085/. The left port is your laptop’s port. The right port is the app’s port inside the container. These are separate from Lecturre on port 8081.

In another terminal, run docker logs lecturre-demo. Stop the foreground container with Ctrl+C. The --rm option removes that stopped demo container, not its image.

Keep images small and clear

Copy only needed files. Use a lock file when your app has packages. A multi-stage build can keep compilers out of the final image. It adds little value to this one-file app, so the example uses one stage.

The base tag python:3.13-slim can move to a newer patch. For a release, review the base image and record an approved digest. A digest identifies exact image content. Rebuild regularly to take security fixes.

Where Kubernetes fits

Docker helps build and run containers. Kubernetes manages groups of workloads across a cluster. It can keep copies running and direct traffic to ready copies. It is not needed just to practise this app.

Read the Kubernetes lesson after this one. It explains Deployments, Services, health checks and scaling.

Practice

Change the app version through APP_VERSION. Build a second image tag. Check the response, then explain the difference between an image tag, a container name and a published port.

Add a small test file

Save this as test_app.py next to app.py. Run python -m unittest -v test_app. It checks the responses, mock data and a real local HTTP request. The secure CI lesson uses this same test file.

import json
import threading
import unittest
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer

from app import COUNTS, DEMO_DATA, Handler, LOCK, metrics_text, payload


class AppTests(unittest.TestCase):
    def test_health(self):
        self.assertEqual(payload("/health"), (200, {"status": "ok"}))

    def test_unknown_path(self):
        self.assertEqual(payload("/missing")[0], 404)

    def test_version(self):
        self.assertEqual(payload("/", version="2.0")[1]["version"], "2.0")

    def test_microservice_roles(self):
        for role in ("products", "orders", "users"):
            self.assertEqual(payload("/", role)[1]["items"], DEMO_DATA[role])

    def test_metrics_start_at_zero(self):
        with LOCK:
            COUNTS.clear()
        self.assertIn('status="500"} 0', metrics_text())

    def test_real_http(self):
        server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        url = f"http://127.0.0.1:{server.server_port}"
        try:
            with urllib.request.urlopen(url + "/health", timeout=3) as response:
                self.assertEqual(json.load(response), {"status": "ok"})
                self.assertTrue(response.headers["X-Request-ID"])
            with self.assertRaises(urllib.error.HTTPError) as error:
                urllib.request.urlopen(url + "/missing", timeout=3)
            self.assertEqual(error.exception.code, 404)
            error.exception.close()
            with urllib.request.urlopen(url + "/metrics", timeout=3) as response:
                self.assertIn('status="404"}', response.read().decode())
        finally:
            server.shutdown()
            server.server_close()
            thread.join(timeout=3)


if __name__ == "__main__":
    unittest.main()

Official references and further reading

Reviewed on 25 October 2026. Tools change, so check the official documentation for the version you use.

DevOps learning roadmap · All DevOps & Cloud tutorials

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?