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
-
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 runbefore pushing — runninguvicornin the working directory tests different bytes than the image does. -
GitHub Actions
Build for two architectures
A push to
maintriggers a Buildx job producinglinux/amd64andlinux/arm64from a single Dockerfile, with the arm64 half emulated through QEMU. Layer cache is stored in the Actions cache between runs. -
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;
latestexists for convenience only. -
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.
-
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.
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 GHCRStorage 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