Docker & Containers — Level by Level›12 · Project: Python front end + back end

Lesson 12 of 13 · Level 6 — Practical projects

Project: Python front end + back end

Containerise a real two-tier app: an nginx front end and a Python (Flask) back end in separate containers, with the full code, both Dockerfiles, a helper script that builds, runs and exposes it, and the Compose file that does the same in one command.

Practitioner

Download the project (zip)Dockerfiles, Python + nginx code, compose.yaml, app.sh

Key wordsprojectPythonFlaskgunicornnginxfront endback endtwo containersreverse proxyexposefirewalldocker compose
Browser any machine :8080 Docker host · one user-defined network frontend nginx (non-root) static site + /api/ proxy backend Python 3.12 Flask + gunicorn :8000 volume quotes-data SQLite -p 8080 by name: backend only the front end publishes a port; the back end is unreachable from outside frontend/Dockerfile backend/Dockerfile
Two containers on one network: nginx serves the site and forwards /api/ to the Python back end; only the front end is published.

What you'll build

A small Quotes board: a web page where you add and read quotes. It's split the way real apps are:

Front end Back end
What Static HTML/JS served by nginx, which also forwards /api/ Python 3.12 API with Flask, served by gunicorn
Image nginxinc/nginx-unprivileged (runs as non-root) python:3.12-slim, non-root user 10001
Port inside 8080 8000
Published Yes (WEB_PORT, default 8080) No
Data – SQLite file on the quotes-data volume

Everything here was built and tested exactly as shown.

Resources needed: one Linux machine or VM with Docker Engine and the Compose plugin (lesson 1), curl, and about 300 MB of disk for images. Docker Desktop on macOS or Windows works too.

python-quotes/
├── app.sh                    helper: build | run | up | expose | test | status | logs | down | clean
├── compose.yaml              the same app in one command
├── backend/
│   ├── app.py                Flask API (SQLite)
│   ├── requirements.txt
│   ├── Dockerfile
│   └── .dockerignore
└── frontend/
    ├── Dockerfile
    ├── default.conf.template nginx config (BACKEND_URL filled in at start)
    └── site/index.html, app.js

Step 1: the back end

A tiny API: list quotes, add a quote, and a health endpoint that also touches the database.

python-quotes/backend/app.py

"""Quotes API: a tiny Flask back end that stores quotes in SQLite.

GET  /api/health   -> {"status": "ok"}
GET  /api/quotes   -> newest quotes first
POST /api/quotes   -> {"text": "...", "author": "..."}
"""
import os
import sqlite3

from flask import Flask, g, jsonify, request

DB_PATH = os.environ.get("DB_PATH", "/data/quotes.db")
app = Flask(__name__)


def db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(_exc):
    conn = g.pop("db", None)
    if conn is not None:
        conn.close()


def init_db():
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute("CREATE TABLE IF NOT EXISTS quotes ("
                     "id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, author TEXT NOT NULL)")
        if conn.execute("SELECT COUNT(*) FROM quotes").fetchone()[0] == 0:
            conn.execute("INSERT INTO quotes (text, author) VALUES (?, ?)",
                         ("It works on my machine. So we shipped the machine.", "Every container ever"))


@app.get("/api/health")
def health():
    db().execute("SELECT 1")
    return jsonify(status="ok")


@app.get("/api/quotes")
def list_quotes():
    rows = db().execute("SELECT id, text, author FROM quotes ORDER BY id DESC LIMIT 50").fetchall()
    return jsonify([dict(r) for r in rows])


@app.post("/api/quotes")
def add_quote():
    data = request.get_json(silent=True) or {}
    text, author = str(data.get("text", "")).strip(), str(data.get("author", "")).strip() or "Anonymous"
    if not text or len(text) > 500 or len(author) > 100:
        return jsonify(error="text is required (max 500 chars), author max 100 chars"), 400
    cur = db().execute("INSERT INTO quotes (text, author) VALUES (?, ?)", (text, author))
    db().commit()
    return jsonify(id=cur.lastrowid, text=text, author=author), 201


init_db()

python-quotes/backend/requirements.txt

flask==3.1.2
gunicorn==23.0.0

The Dockerfile follows lesson 2: dependencies before code for caching, a non-root user that owns the volume's mount point, and a health check that uses Python itself because the slim image has no curl.

python-quotes/backend/Dockerfile

# Back end: Flask API served by gunicorn on port 8000
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DB_PATH=/data/quotes.db

WORKDIR /app

# Dependencies first, so this layer stays cached while only the code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# Non-root user that owns the data folder (the volume is mounted here)
RUN useradd --uid 10001 --no-create-home appuser \
 && mkdir -p /data && chown 10001 /data
USER 10001

EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2)"

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "app:app"]

python-quotes/backend/.dockerignore

__pycache__
*.pyc
.env
.venv
*.db
Dockerfile
$ docker build -t quotes-backend:1.0 ./backend
$ docker run --rm quotes-backend:1.0 id
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)

Step 2: the front end

nginx serves the page and reverse-proxies /api/ to the back end, so the browser only ever talks to one address and there are no cross-origin (CORS) problems. The nginx image fills ${BACKEND_URL} in from the environment when the container starts, so the same image works with any back-end name.

python-quotes/frontend/default.conf.template

# nginx front end: serves the static site and forwards /api/ to the back-end container.
# The BACKEND_URL variable below is filled in from the environment when the container starts (nginx image feature).
server {
    listen 8080;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass ${BACKEND_URL};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 3s;
        proxy_read_timeout 10s;
    }

    location = /healthz {
        access_log off;
        return 200 "ok\n";
    }
}

python-quotes/frontend/Dockerfile

# Front end: static HTML/JS served by nginx (non-root image, listens on 8080)
FROM nginxinc/nginx-unprivileged:1.27-alpine

# Where /api/ requests go. The back-end container's name on the shared network.
ENV BACKEND_URL=http://backend:8000

COPY default.conf.template /etc/nginx/templates/default.conf.template
COPY site/ /usr/share/nginx/html/

EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

The page and its script. Quotes are inserted with textContent, never as HTML, so a quote containing <script> is shown as text.

python-quotes/frontend/site/app.js

// Talks only to /api/ on the same origin; nginx forwards it to the back-end container.
const list = document.getElementById("quotes");
const status = document.getElementById("status");

async function load() {
  try {
    const res = await fetch("/api/quotes");
    if (!res.ok) throw new Error(`API returned ${res.status}`);
    const quotes = await res.json();
    list.replaceChildren(...quotes.map(q => {
      const b = document.createElement("blockquote");
      b.textContent = q.text;                 // textContent: never render user input as HTML
      const c = document.createElement("cite");
      c.textContent = "— " + q.author;
      b.append(c);
      return b;
    }));
    status.textContent = `${quotes.length} quote(s), loaded from the back end`;
  } catch (err) {
    status.textContent = "Back end not reachable: " + err.message;
  }
}

document.getElementById("add").addEventListener("submit", async ev => {
  ev.preventDefault();
  const form = new FormData(ev.target);
  const res = await fetch("/api/quotes", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: form.get("text"), author: form.get("author") }),
  });
  if (res.ok) { ev.target.reset(); load(); }
  else status.textContent = "Could not save: " + (await res.json()).error;
});

load();
$ docker build -t quotes-frontend:1.0 ./frontend

Step 3: run it with plain docker

This is what ./app.sh run does, step by step:

$ docker network create quotes-net
$ docker volume create quotes-data
$ docker run -d --name quotes-backend --network quotes-net --network-alias backend \
    -v quotes-data:/data --restart unless-stopped quotes-backend:1.0
$ docker run -d --name quotes-frontend --network quotes-net \
    -p 8080:8080 -e BACKEND_URL=http://backend:8000 --restart unless-stopped quotes-frontend:1.0
$ docker ps --filter name=quotes --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES             STATUS                    PORTS
quotes-frontend   Up 12 seconds (healthy)   0.0.0.0:8080->8080/tcp
quotes-backend    Up 20 seconds (healthy)   8000/tcp

Start the back end first: nginx resolves backend when it starts and refuses to start if the name doesn't exist yet. Compose handles this with depends_on and the health check.

Check: open http://localhost:8080, add a quote, then docker rm -f quotes-backend and start it again with the same command: the quote is still there, because it lives on the volume.

Step 4: the same with Compose

python-quotes/compose.yaml

# Quotes board: nginx front end + Python back end + a volume for the SQLite database.
#   docker compose up -d --build --wait
# Only the front end publishes a port. Change it with WEB_PORT=9090, or BIND_ADDR=127.0.0.1 to keep it local.
name: quotes

services:
  frontend:
    build: ./frontend
    image: quotes-frontend:1.0
    ports:
      - "${BIND_ADDR:-0.0.0.0}:${WEB_PORT:-8080}:8080"
    environment:
      BACKEND_URL: http://backend:8000
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped

  backend:
    build: ./backend
    image: quotes-backend:1.0
    volumes:
      - quotes-data:/data
    restart: unless-stopped
    # no ports: reachable only from the frontend over the project network

volumes:
  quotes-data:
$ docker compose up -d --build --wait
 ✔ Container quotes-backend-1   Healthy
 ✔ Container quotes-frontend-1  Healthy
$ docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
SERVICE    STATUS                    PORTS
backend    Up 16 seconds (healthy)   8000/tcp
frontend   Up 10 seconds (healthy)   0.0.0.0:8080->8080/tcp

Step 5: expose it to other machines

"Expose" means three separate things, and all three must be right for a colleague's browser to reach your app:

  1. Publish the port on an address other machines can reach. -p 8080:8080 binds all interfaces; -p 127.0.0.1:8080:8080 (what BIND_ADDR=127.0.0.1 does) keeps it on this machine only. EXPOSE in the Dockerfile is documentation and doesn't publish anything.
  2. Allow it through the host firewall (ufw, firewalld, Windows Firewall) and, on a cloud VM, the security group or network firewall.
  3. Reach the host's address from the other machine: same network, VPN, or a public IP.

The helper checks the first two and prints the URLs:

$ ./app.sh expose
==> Open the app:
    on this machine:     http://localhost:8080
    other machines:      http://192.168.56.20:8080
    listening socket:    0.0.0.0:8080
    firewall:            ufw is active
                         open it with: sudo ufw allow 8080/tcp  (or ./app.sh expose --open)
    cloud VM?            also allow TCP 8080 in the security group / network firewall
$ ./app.sh expose --open

Then, from another machine: curl http://192.168.56.20:8080/healthz should print ok.

For anything public, put HTTPS in front

Publishing port 8080 on a public IP serves plain HTTP. For a real deployment, publish only on 127.0.0.1 and put a reverse proxy with a TLS certificate on 443 in front (nginx or Caddy on the host, or another container), or use a cloud load balancer. Lesson 3 covers why published ports can bypass ufw, which matters here.

Step 6: test and troubleshoot

$ ./app.sh test
==> GET http://127.0.0.1:8080/healthz
ok
==> POST http://127.0.0.1:8080/api/quotes
{"author":"app.sh test","id":2,"text":"Containers are just processes."}
==> GET http://127.0.0.1:8080/api/quotes
[{"author":"app.sh test","id":2,...},{"author":"Every container ever","id":1,...}]
Symptom Check
Page loads but says "Back end not reachable" docker logs quotes-backend; is it (healthy)? Same network?
Front end won't start: host not found in upstream "backend" Start the back end first, or use Compose
Works on the host, not from another machine BIND_ADDR, host firewall, security group, routing
curl localhost:8080 is reset, but containers are healthy and work from inside the network A host firewall that filters the host's own outbound connections (some hardened servers only allow ports like 80/443). Docker's port forwarder runs on the host, so its connection to the container's port is blocked. Allow the port for the Docker bridge, or run the app on a port the policy allows.
permission denied writing /data The volume's ownership: the image creates /data owned by UID 10001 before the volume is first used

Test from inside the network when in doubt; it takes the host's firewall and port publishing out of the picture:

$ docker run --rm --network quotes-net curlimages/curl -s http://quotes-frontend:8080/api/quotes

Extend it

  1. Change WEB_PORT to 9090 and restart with ./app.sh up. Which containers were recreated?
  2. Add a DELETE /api/quotes/<id> endpoint, rebuild only the back end (docker compose up -d --build backend), and time the rebuild: the pip install layer is cached.
  3. Add the hardening flags from lesson 10 to the back end (read_only: true, cap_drop: [ALL], a tmpfs for /tmp). What still needs to be writable?
  4. Push both images to your private registry from lesson 7 and run the stack on a second host by changing only the image: names.

Clean-up

$ ./app.sh clean     # containers, network, volume and images

Recap

  • Two images, two containers, one user-defined network: the front end reaches the back end by name.
  • Only the front end publishes a port; the API and its data stay private.
  • app.sh wraps the real commands (build, network create, run, health waits) so you can read exactly what it does. Compose does the same declaratively.
  • Exposing = publish on the right address + open the firewall/security group + a reachable route. Use HTTPS in front for anything public.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.