DevOps · Software Engineering
MiniLink
Containerized URL Shortener + Analytics
MiniLink demo
Problem
Every public URL shortener sends your links (and your users' clicks) through someone else's servers. More importantly for portfolio purposes: public shorteners don't require writing a Dockerfile, wiring up Docker Compose, or deploying to a cloud platform. MiniLink exists to demonstrate those DevOps fundamentals in a scope small enough to reason about completely.
Solution
A self-hosted URL shortener where POST /shorten returns a short code and GET /{code} issues a 302 redirect. Every redirect increments a hit counter in Postgres. A live HTMX dashboard shows the top 20 links by hits. Short-code lookups are served from Redis on the hot path — Postgres is only touched on a cache miss or a write.
Tech stack
APIFastAPI · SQLModel
DatabasePostgreSQL (Railway managed)
CacheRedis — read-through cache, 24-hour TTL (Railway managed)
ContainerDocker — multi-stage build (builder → runtime, ≈150 MB image)
Local devDocker Compose — one command brings up API + Postgres + Redis
CI/CDGitHub Actions: lint (ruff) → test (pytest) → build image → push to GHCR → Railway auto-deploy
Dashboard UIHTMX + Tailwind CSS (no React, no build step)
DeployRailway — managed services, GitHub App auto-deploy on push to main
Architecture
Browser
  │
  ▼
FastAPI  (Railway — Dockerfile, port 8080)
  │
  ├─ short-code lookup
  │       │
  │       ▼
  │    Redis  ──── miss ────►  PostgreSQL
  │   (cache)  ◄──── fill ───  (source of truth)
  │
  └─ hit counter write ──────► PostgreSQL
Reads hit Redis first with a 24-hour TTL. A cache miss falls through to Postgres, returns the URL, and warms the Redis entry. Writes (new short code, hit counter increment) go straight to Postgres; the Redis entry is written through on create and invalidated on a consistency check failure.
Selected lessons
  • Multi-stage Docker builds matter for image size. A naive single-stage image for this app exceeds 1 GB — the pip install layer balloons. Separating the builder stage (installs deps into /install) from the runtime stage (copies only the artifact) shrinks the final image to ≈150 MB. Smaller images mean faster Railway deploys and less storage cost.
  • Docker Compose healthchecks prevent startup races. The API's depends_on with a pg_isready healthcheck on the Postgres service means uvicorn never starts before the DB is ready. Without this, create_db_and_tables() fails on the first boot and the container exits silently.
  • Cloud proxy auto-detection can quietly pick the wrong port. Railway's edge proxy detected port 8000 (from the old EXPOSE 8000 in the Dockerfile) while uvicorn was actually binding to 8080 (via Railway's injected $PORT). Every request returned 502 with no application-level log. The fix was aligning EXPOSE to 8080 and explicitly setting the Target Port in Railway's dashboard. Always confirm where your proxy thinks your app is listening.
  • Redis TTL is a product decision, not a technical detail. A 24-hour TTL is fine for a personal shortener where links rarely change. But if you add a delete/edit feature, the cache entry must be invalidated on write — otherwise visitors hit the stale cached redirect for up to 24 hours after deletion. Design the invalidation path before you design the feature.
Local setup
git clone https://github.com/neuralxjam/minilink
cd minilink
python -m venv .venv && .venv\Scripts\pip install -r requirements-dev.txt
docker compose up          # starts API + Postgres + Redis
# Visit http://localhost:8000
© 2026 Jhames Andrew Macabata