Skip to content
Laviyx Learn

Try

Docker and Kubernetes course menu

Docker Project: Notes App with Nginx, API and PostgreSQL

Build a complete three-service app with Docker Compose: an Nginx reverse proxy, a Python API, PostgreSQL with a volume, health checks, a private network and a non-root user.

Intermediate12 min readUpdated
On this page

In this project you will build a small but realistic system of three containers:

Text
Browser --> nginx :8080 --> api :8000 --> db :5432
            (public)        (private)     (private)

Only Nginx is reachable from outside. The API and database are on a private network, so this project makes a good portfolio piece.

Step 1: Project layout#

Terminal
mkdir notes-app
cd notes-app
mkdir api web
cat > .env <<'EOF'
POSTGRES_USER=notes
POSTGRES_PASSWORD=change-me-locally
POSTGRES_DB=notes
EOF
cat > .gitignore <<'EOF'
.env
EOF
sed 's/=.*/=/' .env > .env.example
cat .env.example
Output
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=

Step 2: The API#

Terminal
cat > api/app.py <<'EOF'
import os

import psycopg
from flask import Flask, jsonify, request

app = Flask(__name__)
DB_URL = os.environ["DATABASE_URL"]


def init_db():
    with psycopg.connect(DB_URL) as conn:
        conn.execute("CREATE TABLE IF NOT EXISTS notes (id serial PRIMARY KEY, body text NOT NULL)")


init_db()


@app.get("/api/health")
def health():
    with psycopg.connect(DB_URL) as conn:
        conn.execute("SELECT 1")
    return jsonify(status="ok")


@app.get("/api/notes")
def list_notes():
    with psycopg.connect(DB_URL) as conn:
        rows = conn.execute("SELECT id, body FROM notes ORDER BY id").fetchall()
    return jsonify([{"id": i, "body": b} for i, b in rows])


@app.post("/api/notes")
def add_note():
    body = (request.get_json(silent=True) or {}).get("body", "").strip()
    if not body:
        return jsonify(error="body is required"), 400
    with psycopg.connect(DB_URL) as conn:
        row = conn.execute("INSERT INTO notes (body) VALUES (%s) RETURNING id", (body,)).fetchone()
    return jsonify(id=row[0], body=body), 201
EOF
cat > api/requirements.txt <<'EOF'
flask==3.1.*
gunicorn==23.*
psycopg[binary]==3.2.*
EOF
cat > api/Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --create-home app
USER app
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
EOF

Step 3: The web server#

Nginx serves a static page, and passes /api/ to the API container, which is called a reverse proxy:

Terminal
cat > web/index.html <<'EOF'
<!doctype html>
<title>Notes</title>
<h1>Notes</h1>
<ul id="notes"></ul>
<script>
  fetch("/api/notes").then((r) => r.json()).then((notes) => {
    document.getElementById("notes").innerHTML =
      notes.map((n) => `<li>${n.body.replace(/</g, "&lt;")}</li>`).join("");
  });
</script>
EOF
cat > web/nginx.conf <<'EOF'
server {
  listen 80;
  root /usr/share/nginx/html;

  location /api/ {
    proxy_pass http://api:8000;
    proxy_set_header Host $host;
  }
}
EOF
cat > web/Dockerfile <<'EOF'
FROM nginx:1.30-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
EOF

http://api:8000 uses the Compose service name.

Step 4: compose.yaml#

Two networks keep the database away from Nginx:

Terminal
cat > compose.yaml <<'EOF'
services:
  web:
    build: ./web
    ports:
      - "8080:80"
    depends_on:
      api:
        condition: service_healthy
    networks: [front]
    restart: unless-stopped

  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
    networks: [front, back]
    mem_limit: 256m
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    env_file: .env
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
      interval: 2s
      timeout: 3s
      retries: 15
    networks: [back]
    restart: unless-stopped

networks:
  front:
  back:

volumes:
  db-data:
EOF
docker compose config --services | sort
Output
api
db
web

Step 5: Start it#

--wait blocks until every service is healthy:

Terminal
docker compose up -d --build --wait --quiet-pull > /dev/null 2>&1
docker compose ps --format '{{.Service}} {{.Health}}' | sort
Output
api healthy
db healthy
web

Step 6: Use it#

Terminal
curl -s -X POST http://localhost:8080/api/notes -H "Content-Type: application/json" -d '{"body": "Learn Docker"}'
curl -s -X POST http://localhost:8080/api/notes -H "Content-Type: application/json" -d '{"body": "Build a project"}'
curl -s http://localhost:8080/api/notes
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/api/notes -H "Content-Type: application/json" -d '{}'
curl -s http://localhost:8080/ | grep -o '<h1>.*</h1>'
Output
{"body":"Learn Docker","id":1}
{"body":"Build a project","id":2}
[{"body":"Learn Docker","id":1},{"body":"Build a project","id":2}]
400
<h1>Notes</h1>

Step 7: Check the isolation#

The API runs as a normal user, and Nginx cannot reach the database:

Terminal
docker compose exec api whoami
docker compose exec web sh -c 'nslookup db > /dev/null 2>&1 && echo "web can see db" || echo "web cannot see db"'
curl -s --max-time 2 http://localhost:5432 > /dev/null || echo "Database port is closed to the host"
Output
app
web cannot see db
Database port is closed to the host

Step 8: Check that data survives#

Terminal
docker compose down > /dev/null 2>&1
docker compose up -d --wait > /dev/null 2>&1
curl -s http://localhost:8080/api/notes
docker compose down -v > /dev/null 2>&1
Output
[{"body":"Learn Docker","id":1},{"body":"Build a project","id":2}]

Extend the project#

  • Add GitHub Actions to build and push the images.
  • Add Redis to cache the list of notes.
  • Run the API with several replicas: docker compose up -d --scale api=3.
  • Add a DELETE route, tests, and a nightly pg_dump backup.
  • Deploy it to a cheap server, and add HTTPS.
  • Convert it to Kubernetes with Deployments and Services.

Put it on your resume#

Publish it on GitHub, with a README that shows the diagram above and one start-up command. You can honestly describe it as: "Containerised a three-tier app with Docker Compose, using health checks, a private database network and a non-root user."

Key takeaway#

Next: Docker interview questions.

Finished this lesson?

Some links are affiliate links. If you buy through them, Laviyx Learn may earn a commission at no extra cost to you. As an Amazon Associate, we earn from qualifying purchases.