A minimal URL shortener MVP: a web client, two API services, and an API gateway in front of PostgreSQL.
Browser
│
▼
┌───────────────┐ /api/urls (POST) ┌────────────────┐
│ NGINX │ ───────────────────────▶│ write-service │
│ gateway │ │ (.NET, EF) │
│ (serves the │ /api/urls/{code} GET ┌────────────────┐
│ Astro client │ ───────────────────────▶│ read-service │
│ statically) │ │ (.NET, Dapper)│
└───────────────┘ └───────┬────────┘
│
┌────▼────┐
│PostgreSQL│
└─────────┘
| Component | Role |
|---|---|
gateway |
NGINX — serves the built client and routes /api/urls by method |
write-service |
.NET 10 + EF Core — creates short URLs (POST /api/urls) |
read-service |
.NET 10 + Dapper — resolves short codes to 302 redirects |
client |
Astro (TypeScript) — static UI, built into the gateway image |
postgres |
Single short_urls table, schema auto-applied on first boot |
Request flow:
POST /api/urlswith{ "originalUrl": "..." , "expiresAt": "..."? }→201+ the created record{ shortCode, originalUrl, createdAt, expiresAt }. Expiry defaults to now + 24 hours.GET /api/urls/{shortCode}→302redirect to the original URL, or404when unknown/expired.- Errors follow RFC 9457 (
application/problem+json).
Prerequisites: Docker with Compose.
docker compose up --build -dThen open http://localhost.
Services listen on a private network; only the gateway is exposed on :80 (client page + API). Postgres is not published to the host.
PUBLIC_API_URL(gateway build arg, defaulthttp://localhost) — base URL the browser uses for API calls. Override it when deploying so the client calls the API on the origin the page is served from, e.g.PUBLIC_API_URL=https://your-domain docker compose up --build -d.- Database connection strings are injected into the .NET services via
ConnectionStrings__devDatabase. - Local dev: copy
client/.env.exampletoclient/.envfor the Astro dev server.
- Postgres:
docker run -d --name url-shortener-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=urlshortener -p 5432:5432 -v "$PWD/database/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro" postgres:17-alpine - Write service:
dotnet run --project services/writeService/src --urls http://0.0.0.0:5047 - Read service:
dotnet run --project services/readService/src --urls http://0.0.0.0:5291 - Client:
npm install && npm run devinclient/ - For a host-mode gateway, use
gateway/nginx.confwith upstreams pointed athost.docker.internal:<port>(the committed config targets compose service names).