Portfolio

Case study

Rendered from the project README. The demo uses fictional data.

StackPilot

Reference production stack and live status page for a small inventory API. The fictional tenant is Acme Logistics. The page you can click is a real process: SQLite, host metrics from /proc, and gzipped snapshots on a timer. docker-compose.yml is the shape you would run on a VPS when you want Postgres, nginx, and a backup sidecar.

Overview

StackPilot is a portfolio piece for shipping a small SaaS API onto one machine without pretending the demo container is the production container. The same FastAPI app speaks Postgres when DATABASE_URL is set and SQLite otherwise. The status page shows which mode you are in, how fresh the backups are, and whether the request came through a reverse proxy.

Problem → Solution → Result

Problem

Solution

Result

These are capabilities of this demo, not claims about a client engagement.

Features

Architecture

Live demo (what the public page is):

Browser
         |
         |  relative URLs (api/..., static/..., ../)
         v
      edge nginx, prefix stripped
         |
         v
      systemd: python -m app  127.0.0.1:18801
         |
         +--> SQLite   DATA_DIR/inventory.db
         +--> snapshots DATA_DIR/backups/*.db.gz   (15 min, keep 12)

Reference Compose stack (docker-compose.yml):

Browser
         |
         v
      proxy (nginx) :8080 and :8443
         |   gzip, security headers, rate limit, X-Real-IP / X-Forwarded-For
         v
      api (FastAPI, uid 10001) ----> db (postgres:16-alpine, volume pgdata)
                                       ^
                                       |
                                  backup sidecar
                                  pg_dump | gzip
                                  7 daily + 4 weekly  (volume backupdata)
flowchart LR
        browser[Browser] --> proxy[nginx proxy]
        proxy --> api[FastAPI api]
        api --> db[(Postgres 16)]
        backup[backup sidecar] --> db
        backup --> vol[(backup volume)]
        api -. read only .-> vol

The API does not shell out to pg_dump. In Compose mode it only stats the files the sidecar wrote. In demo mode it writes SQLite snapshots itself. Operations steps, including the restore drill and the hardening checklist, are in docs/runbook.md.

Notes:

Tech stack

Run locally

venv

python3 -m venv .venv
      .venv/bin/pip install -r requirements-dev.txt
      .venv/bin/python -m app

Or ./run.sh, which creates .venv if needed and listens on 127.0.0.1:18801. Open http://127.0.0.1:18801/.

The banner on that page should read: live demo mode, native process, Compose file is the reference deployment.

Docker Compose

Docker is the reference deployment. It is not required to view the demo.

cp .env.example .env
      # edit POSTGRES_PASSWORD; keep it URL-safe
      docker compose up -d --build
      curl -fsS http://127.0.0.1:8080/healthz

Makefile targets: up, down, logs, backup, restore, test, venv.

make up
      make backup
      make restore FILE=backups/daily-YYYYMMDDTHHMMSSZ.sql.gz CONFIRM=yes

make restore will refuse until CONFIRM=yes. Prefer the scratch-database drill in the runbook before you point it at the primary database.

Configuration

VariableDefaultRole
PORT18801Listen port
HOST127.0.0.1Bind address. Compose sets 0.0.0.0 so the proxy can reach the API
DATA_DIR./dataSQLite file and, in demo mode, the backup directory parent
BACKUP_DIR$DATA_DIR/backupsSnapshot directory. Compose sets /backups
DATABASE_URLemptyEmpty or sqlite uses SQLite. postgresql://... uses Postgres
BACKUP_INTERVAL_SECONDS900 (minimum 60)Demo scheduler period, and the sidecar period in Compose (Compose example uses 86400)
GIT_COMMITc4e1a91Displayed commit, 7–40 hex characters. Anything else is ignored
POSTGRES_USERstackpilotCompose only
POSTGRES_PASSWORDchange-me-demo-onlyCompose only. Placeholder, change it before a real deploy
POSTGRES_DBstackpilotCompose only

SQLite demo retention is fixed at the newest 12 files so the directory cannot grow without a bound. The sidecar keeps 7 daily dumps and 4 weekly dumps.

API endpoints

MethodPathNotes
GET/Status page
GET/healthz{"status":"ok",...}. No database work
GET/api/statusComponents, host, backups, uptime history, deployments
GET/api/itemsFictional Acme Logistics items
GET/api/warehousesFictional warehouses
GET/api/backupsSnapshot list, retention, next run
POST/api/backups/runSQLite demo only. 429 when rate limited, 409 on Postgres, 413 above 8 KB
GET/metricsPrometheus text: stackpilot_up, stackpilot_http_requests_total, stackpilot_backup_age_seconds

Paths in the browser are relative (api/items, not /api/items) so the page works both at the bare port and behind a stripped prefix.

Tests

.venv/bin/python -m pytest
      .venv/bin/ruff check app tests

The suite checks health, the status JSON, inventory, a real gzipped SQLite snapshot (via the backup API) and the keep-12 cap, both rate-limit scopes, the body cap, relative URLs, Prometheus text, the Compose file, the nginx tokens, the shell scripts, the systemd unit, and the Dockerfile USER / HEALTHCHECK.

CI (.github/workflows/ci.yml) runs that lint and test matrix on Python 3.9 and 3.11, then docker build of the API image. The workflow lives with this project. If the git root is the parent monorepo, the job still finds stackpilot/requirements-dev.txt.

Deployment notes

The live demo is a systemd service behind nginx on a small VPS, not a container. The unit is deploy/stackpilot.service (User=ec2-user, WorkingDirectory=/opt/portfolio/stackpilot, PORT=18801, DATA_DIR=/opt/portfolio/stackpilot/data, Restart=always, MemoryMax=150M, NoNewPrivileges=yes). Install steps are in docs/runbook.md.

Intended public URL: https://3.1.79.63/portfolio/stackpilot/ with the prefix stripped by nginx (proxy_pass http://127.0.0.1:18801/;). The page links back to the portfolio with ../. This repository does not install the unit or edit the edge proxy; that is a later step.

The Compose file remains the reference for a single-VPS production shape: private database network, TLS-ready proxy, healthchecks, log rotation, and a restore drill. Honest limit: one API replica, so a deploy is a short blip masked by the proxy, not a rolling update across a fleet.

Screenshots

Desktop, 1440×1000:

Desktop status page

Mobile, 390×844:

Mobile status page

Open the live demo