DevOps · Software Engineering
MiniLink
Containerized URL Shortener + Analytics
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
| API | FastAPI · SQLModel |
| Database | PostgreSQL (Railway managed) |
| Cache | Redis — read-through cache, 24-hour TTL (Railway managed) |
| Container | Docker — multi-stage build (builder → runtime, ≈150 MB image) |
| Local dev | Docker Compose — one command brings up API + Postgres + Redis |
| CI/CD | GitHub Actions: lint (ruff) → test (pytest) → build image → push to GHCR → Railway auto-deploy |
| Dashboard UI | HTMX + Tailwind CSS (no React, no build step) |
| Deploy | Railway — 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_onwith apg_isreadyhealthcheck 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 8000in 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 aligningEXPOSEto 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