URL shortener

A small FastAPI service, containerised and built once in CI, running unchanged on a Raspberry Pi and on Azure Container Apps. The database credential lives in Key Vault and is never stored in the repository, the image, or the platform configuration.

Source on GitHub  ·  Infrastructure in shortener-infra

What this was for

The application is deliberately small. A URL shortener is four endpoints and one table, which means nothing about the domain competes for attention with the things this project was actually built to practise: containerisation, a build pipeline, externalised state, and handling a credential without storing it anywhere.

It was built in three stages, each one adding a single capability so that when something broke there was only ever one candidate. First a working app with links held in a Python dictionary. Then a container image built in CI and deployed to Azure. Then managed PostgreSQL, with the connection string moved into Key Vault.

Keeping the stages separate was the most useful decision in the project. Every failure that came up had a short list of possible causes, because only one thing had changed since the last working state.

The service

Four routes. POST /shorten accepts a JSON body with a URL, generates a random code, stores the pair, and returns the short link. GET /{code} looks the code up and issues a 307 redirect. GET /health runs a query against the database. The root path renders a minimal landing page showing the configured site name and a count of stored links.

Request validation is handled entirely by Pydantic's HttpUrl type, so a malformed URL returns a 422 with field-level detail and no validation code was written. FastAPI generates interactive OpenAPI documentation from the same type hints, which made testing the deployed service straightforward without writing a client.

Route ordering

GET /{code} matches any single path segment, so it has to be declared last. Defined above /health, FastAPI would treat the string health as a short code and the health endpoint would return a 404. Reserved paths are also excluded from code generation so a generated code can never shadow a real route.

Configuration and secrets

Every environment-specific value reaches the application through an environment variable. There is no configuration file, and nothing about any particular environment is baked into the image. The same image bytes run locally against a containerised PostgreSQL and in Azure against a managed one; only the injected configuration differs.

That constraint is what makes the secret handling possible. Because the application only ever reads DATABASE_URL from its environment, it neither knows nor cares that in Azure the value arrives from Key Vault.

DATABASE_URL = os.environ["DATABASE_URL"]

Square brackets rather than os.getenv, deliberately. There is no sensible default for a database connection, so a missing value should stop the process at import time rather than let it start and point somewhere unintended. That choice paid off during deployment — the failures it produced were immediate and loud rather than subtle.

How the credential reaches the container

The connection string is stored as a secret in Azure Key Vault. The Container App has a system-assigned managed identity, which is granted the Key Vault Secrets User role on that vault. The app's DATABASE_URL is configured as a Key Vault reference rather than a literal, and the platform resolves it at container startup using that identity.

The property worth stating plainly: there is no credential to store. Not a client secret, not a certificate, not a connection string in an environment variable that a reader on the subscription could see. The platform vouches for the application's identity directly, which eliminates the usual bootstrap problem of needing a credential to fetch a credential.

The secret reference deliberately omits the version, so it resolves to whatever is current. Rotating the database password becomes a single vault update with no application redeployment.

Key Vault references resolve at container start, not continuously. A running replica holds the value it was given, so a rotated secret takes effect on the next start rather than immediately.

Build and deploy pipeline

  1. Raspberry Pi

    Develop and commit

    Written over Remote-SSH against a local PostgreSQL container, with the schema applied by Alembic. The built image is run with docker run before pushing — running uvicorn in the working directory tests different bytes than the image does.

  2. GitHub Actions

    Build for two architectures

    A push to main triggers a Buildx job producing linux/amd64 and linux/arm64 from a single Dockerfile, with the arm64 half emulated through QEMU. Layer cache is stored in the Actions cache between runs.

  3. GHCR

    Publish a tagged image

    Pushed to GitHub Container Registry tagged with the commit SHA, authenticated with the workflow's own short-lived token rather than a stored credential. The SHA tag is what gets deployed; latest exists for convenience only.

  4. Azure

    Create a revision

    Container Apps pulls the image and creates a new revision. The previous revision drains while the new one takes traffic, so the swap is not a hard cutover.

  5. Azure

    Resolve configuration and start

    The platform authenticates to Key Vault with the app's managed identity, retrieves the connection string, and injects it into the container's environment. The process starts, opens a pooled TLS connection to PostgreSQL, and begins serving.

Deploying the new tag to Azure is still a manual step. Automating it requires OIDC federation between GitHub Actions and Entra ID, so the pipeline can authenticate without a stored service principal secret.

Repository structure

shortener/ ├── main.py FastAPI app: routes and request models ├── db.py Engine, model, session dependency ├── alembic.ini Placeholder URL; real value injected at runtime ├── migrations/ │ ├── env.py Reads DATABASE_URL, targets Base.metadata │ └── versions/ Committed migration scripts ├── Dockerfile ├── .dockerignore Excludes .venv, .git, __pycache__, .env ├── docker-compose.yml PostgreSQL for local development only ├── requirements.txt Pinned versions ├── .env.example Documents required variables, no values └── .github/workflows/ └── build.yml Multi-arch build and push to GHCR

Storage is isolated in db.py. Route handlers receive a session through FastAPI's dependency injection and never construct one themselves. That separation is why replacing the in-memory dictionary with PostgreSQL changed the handlers very little, and it is the same boundary that would make replacing PostgreSQL tractable.

The compose file starts PostgreSQL only. The application runs directly on the host during development so that --reload picks up changes without a rebuild.

Decisions worth explaining

Connection pre-ping

The engine is created with pool_pre_ping=True, which tests each pooled connection before handing it to the application. Managed databases and intervening firewalls close idle connections silently; without pre-ping, the application receives a dead connection and fails on its first query. The failure is intermittent, correlates with low traffic rather than high, and is genuinely unpleasant to diagnose after the fact. One line prevents the entire class of problem.

Session lifecycle

The session dependency yields inside a try and closes in a finally, so the connection returns to the pool whether the request succeeded or raised. Leaked connections exhaust the pool, at which point the application hangs rather than errors — a worse failure mode than crashing, because nothing obvious appears in the logs.

Migrations, not create_all

The first working version created its table at application startup. That works exactly once: it cannot alter a table that already contains data, which means the first schema change after go-live has no path forward.

Alembic replaced it with versioned scripts committed alongside the code and an alembic_version table recording what has been applied. The application no longer touches schema at all. Creating tables became a deployment concern rather than an application one, which is where it belongs.

Autogeneration is treated as a starting point rather than an authority. It detects new tables and columns reliably but misses renames, type changes, and constraint alterations, so every generated migration is read before it is applied.

Dockerfile layer ordering

Dependencies are copied and installed before the application source. Docker invalidates every layer below a changed one, so with the source copied first, editing a single line would reinstall every dependency. On a Raspberry Pi that is the difference between a two-second rebuild and a two-minute one, and the effect is immediately visible in the build output.

The container also runs as a non-root user, and the .dockerignore keeps the virtualenv, git history and any local .env out of the build context — which matters doubly for a public image, since every layer is inspectable by anyone who pulls it.

Two failures worth recording

A container that never started

After adding the database layer, the new Azure revision stuck in Activating and never became healthy. Container Apps writes container stdout to Log Analytics, and querying that table filtered to the failing revision returned a full Python traceback ending in:

ModuleNotFoundError: No module named 'db'

The Dockerfile still carried COPY main.py . from when the application was a single file. Adding db.py worked locally because both files sit in the working directory; the image only ever contained one of them. The fix was a one-word change to COPY . ., relying on the .dockerignore to keep the build context clean.

What actually changed as a result was the local testing habit. Running uvicorn in the project directory and running the built image are different things, and only one of them is what the platform executes. A docker run of the built image before pushing would have surfaced this in about ten seconds.

Reading the shape of a failure

A later deployment failed differently, and the useful information was in what the platform events didn't contain. Replicas were being scheduled once a minute with no image pull event, no container creation event, and an empty containers array on every replica — meaning the failure was happening before the image was ever run.

That ruled out the application entirely and pointed at the platform layer. Isolating it by temporarily deploying a known-good public image with no secrets attached confirmed the environment, ingress and scheduling were all fine, which narrowed the problem to the specific image and secret configuration.

Once the events did show Created container followed by terminated with exit code 1, the diagnosis changed completely: a container that starts and exits is an application problem with a traceback waiting in the console logs. A container that is never created is a platform problem, and there is nothing to read.

Known limitations

Stated plainly, because a project write-up that claims more than it delivers is worse than one that is clear about its scope.

Deployment is not fully automated. CI builds and publishes the image; updating the Container App to a new tag is manual. Closing that gap needs OIDC federation so the workflow can authenticate to Azure without a stored credential.

Migrations run by hand from a developer machine against an IP-allowlisted database. Moving them into CI is blocked by the firewall — a hosted runner is neither the admin IP nor an Azure service. Migration ordering relative to deployment is also a real distributed-systems problem rather than a formality: new code against an old schema breaks, and old code against a new schema breaks differently. Handling it properly means expand-and-contract migrations.

The health check is shallow. It runs SELECT 1, so it reports healthy when the connection works but the schema is missing — which is exactly the state after provisioning a new database and forgetting to migrate. A check that touched a real table would have caught it.

No retry on startup. A database that is briefly unreachable when the container starts kills the process rather than triggering a backoff.

No authentication, rate limiting, link expiry, or analytics. Anyone who can reach the API can create links, and codes are permanent.

Stack

Application
Python 3.12, FastAPI, Pydantic, SQLAlchemy 2.0, Alembic, psycopg 3
Build
Docker, Buildx, QEMU, GitHub Actions, GitHub Container Registry
Runtime
Azure Container Apps, Azure Database for PostgreSQL Flexible Server, Azure Key Vault, Entra ID managed identity
Development
Ubuntu Server on Raspberry Pi 4, VS Code Remote-SSH, Docker Compose