Case study · A festival bar operator
Offline upgrades for a festival bar's inventory system
We built a festival bar operator's stock and cash-flow system to upgrade its Docker-hosted database on a box with no internet, with every upgrade path rehearsed first on a restore of real event data.
At a glance
- Sector
- A festival bar operator
- Duration
- since March 2026
- Our role
- field research, design, build, deployment tooling and support for the inventory system and the bar till
- Team
- TQM Invest, one engineer, with the client's operators
- Docker Compose
- PostgreSQL
- Alembic
- FastAPI
- Next.js
- Nginx
- Bash
- .NET 8 (till)
Headline results
- Upgrades rehearsed on a restore of the newest production backup before a bundle ships, with an invariant battery that runs again on the box after every install.
- Two upgrade faults caught before they reached a site: a migration step that exited 0 without migrating, and a fresh install that rolled back to an empty database.
- The till has taken real sales at an event, by our records.
The situation
The client runs bars at outdoor festivals: a few days at a site, several bar points, crates of stock arriving on supplier invoices, and complimentary drinks to account for. The stock and cash-flow system runs on a single small computer at the site. There is no reliable internet at the venue, so there are no registry pulls, no cloud database and no remote hands.
The system's ledger is append-only, enforced in the database by triggers that reject updates, deletes and truncates. Corrections are new rows pointing at the row they reverse. That keeps stock and money auditable, and it also means a bad migration cannot be patched by hand on site. Whatever the upgrade does to real data has to be right before the box leaves the office.
Alongside it we built a touch till for the bars, which drives a receipt printer and a card terminal. It is designed so a lost reply from the printer never causes a second receipt. This page is about the inventory system's upgrade path.
What we did
One tarball for a site with no internet
Each release is a single tarball: the application images, the vendored database, cache and proxy images saved with docker save, the compose file, an idempotent installer, the invariant library described below, and a VERSION file with the commit and build date. Nothing is pulled at the site. The installer generates secrets on first run only, detects the LAN address, issues a self-signed certificate for it, and is safe to run again.
A backup before every migration
Before migrating, the installer dumps the database and keeps the newest 30 dumps. An illustration of the pattern:
set -euo pipefail
backup="$BACKUP_DIR/inventory-$(date +%Y%m%d-%H%M%S).sql.gz"
if docker compose exec -T db pg_dump -U app -d app --clean --if-exists | gzip > "$backup"; then
ls -1t "$BACKUP_DIR"/inventory-*.sql.gz | tail -n +31 | xargs -r rm -f
else
rm -f "$backup" # never keep a partial dump
echo "warning: pre-migration backup failed" >&2
fi
The first line carries the weight. Without pipefail, the pipeline's status is gzip's, so a failed dump leaves a valid-looking, empty file that the retention step then keeps as the newest backup.
Upgrades rehearsed on the newest real backup
After the first festival we went through what had happened on site, and one finding was that our tests had only ever run on data we invented. A migration that is wrong for real data cannot be fixed at the venue.
So we built a local harness. It takes the newest production dump, restores it into a scratch database, snapshots row counts, runs alembic upgrade head, runs an invariant battery, compares row counts with a committed baseline of expected changes, and boots the API against the result. The festival's own dump was carried this way through fourteen migrations.
The battery checks, among other things, that no foreign key points at a missing row. It does not list the foreign keys by hand. It reads them all from the Postgres catalog (pg_constraint), so a table added next month is covered on the day it lands. The expected-change baseline is a plain text file next to the migrations, with one rule in its header: update it in the same commit as any migration that changes expected behavior. A row count that moves without a matching line in that file fails the rehearsal.
The same battery library ships inside the bundle and runs on the box after every install, against the data that is really there.
The rebuild ships the same way
The inventory system was later rebuilt on a fresh migration chain. It sets one transaction per migration from its first revision (the second fault below explains why) and refuses to start if the database URL is unset, so a misconfigured install never targets a placeholder. The rebuilt version now ships as an offline bundle too.
Results
| Measure | Result |
|---|---|
| Install at the site | One tarball, no network needed, safe to re-run |
| Backups | A dump before every migration; a failed dump leaves no file; newest 30 kept |
| Upgrade path | Rehearsed on a restore of the newest production backup; battery re-run on the box |
| Faults caught before reaching a site | 2: a migration that exited 0 without migrating, a fresh install that rolled back to empty |
| Till | Real sales taken at an event, by our records |
Related case studies
A North American medical-device company
Infrastructure for a medical-device company's clinical platform
Since December 2024 we have run the infrastructure under a medical-device company's clinical platform, and today no deploy opens SSH from CI to any of its hosts.
No deploy opens SSH from CI: host addresses, ports and private keys left the CI secrets in January 2026.
- AWS EC2
- VPC
- RDS
- Docker Swarm
- Docker Compose
Our own product
A nightly batch pipeline that cannot report an empty run as success
We rebuilt our own nightly Docker pipeline on one small VM so that a run that delivers nothing fails with a written reason, and cut its runtime from 2h43m to under an hour.
Nightly runtime fell from 2h43m45s (91% of a three-hour cap) on September 2, 2026 to 54m59s (17% of a five-hour cap) on September 5, and all eleven nights from September 20 to 30 exited clean.
- Python
- Docker Compose
- systemd
- PostgreSQL (managed)
- Prisma
An event-ticketing platform
A multi-tenant ticketing platform: zero-downtime releases and a payment path tested against double charges
We build and run a multi-tenant ticketing platform whose largest release applied eight database migrations in under 20 seconds and served the old code on the new schema for about 2.5 minutes with zero errors.
Eight migrations in under 20 seconds in the September 2026 release, then about 2.5 minutes of old code on the new schema with zero errors and production answering 200 throughout, as observed in logs and monitoring.
- Next.js
- TypeScript
- PostgreSQL
- Prisma
- Stripe
Running into something like this?
Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.