Hosting
Bottle is four processes and a map. It fits on one virtual machine costing about £7 a month, and this page is how.
Everything here assumes test mode: one box, a handful of businesses, and nothing that needs to survive the box catching fire beyond a nightly backup. Scaling is a different page, and not one worth writing yet.
What it runs on
Section titled “What it runs on”A Hetzner CX32: 4 vCPU, 8 GB, 80 GB NVMe, in Falkenstein or Helsinki.
| Item | Monthly |
|---|---|
| CX32 | about €6.80 |
| Automated backups, nightly, seven kept | about €1.36 |
| DNS, through Cloudflare | nothing |
| Email, through SES or a free tier | pennies |
| Nightly database dump to object storage | nothing |
| Total | about €8 |
The 8 GB is for the routing data, not the application. Bottle itself, Postgres included, sits comfortably under a gigabyte.
Hetzner has no UK region. UK GDPR allows transfers to the EEA, so Germany or Finland is fine, but your privacy notice has to say where the data lives. If you would rather be on UK soil, OVH London or Scaleway do the same specs for £10 to £14.
The parts
Section titled “The parts”Five containers on a private network, defined in deploy/docker-compose.yml.
Caddy is the only one with a port on the host. It gets the certificate,
renews it, and hands /api to the API and everything else to the web app, so
the browser only ever talks to one origin and the session cookie is
first-party.
web is Next, built standalone.
api is the Hono server. It runs its migrations as it starts, from the files in its own image, so the schema and the code that expects it move together.
db is Postgres 17 on a named volume.
osrm serves the roads. Reachable from the other containers and from nowhere else, which matters more here than anywhere: a routing engine is unauthenticated compute, and one on a public port is somebody else’s free CPU inside a week.
Getting it up
Section titled “Getting it up”# On a fresh Debian box, as root. Installs Docker, makes a non-root user,# closes everything but SSH and HTTPS, turns on automatic security updates.curl -fsSL .../deploy/bootstrap.sh | bash -s -- "ssh-ed25519 AAAA... you@laptop"
# Then as the bottle usergit clone <repo> /srv/bottle/appcp /srv/bottle/app/deploy/env.example /srv/bottle/.env# fill it in: openssl rand -base64 32 for each secret
cd /srv/bottle/app/deploy./osrm-prepare.sh # once, about an hourdocker compose --env-file /srv/bottle/.env up -d --buildPoint an A record at the box, proxied through Cloudflare, and that is the deployment.
Shipping a new version
Section titled “Shipping a new version”From the repo, one command:
deploy/ship.sh # copy the code up, then build, restart and checkdeploy/ship.sh --dry-run # say exactly what would go up, change nothingIt refuses to ship a tree with uncommitted changes, because what went up would
then be a version nobody can get back from the repo. Pass --force if you mean
it, and the stamp it leaves is marked dirty.
deploy/deploy.sh on the box does the build itself: pull or use what was
copied up, build, publish the manual, restart, check the health endpoint, and
shout with the logs if it did not come back.
Why the two are one command
Section titled “Why the two are one command”The box has no git checkout. The code gets there by rsync, so deploy.sh on
its own has nothing to pull and will happily rebuild whatever is already
sitting there. On 28 September 2026 that was a deploy that printed Healthy
and shipped nothing: the images were rebuilt from the previous commit and the
containers restarted, so every sign said it had worked.
So ship.sh copies the code and then stamps it, writing the commit and the
time into .shipped at the root, last and separately, so a copy that died
halfway leaves no stamp at all. deploy.sh reads that stamp, prints what it is
about to build, and stops rather than builds when:
- there is no stamp, so nothing records which commit the code is
- the stamp’s date cannot be read, so its age is unknown
- the commit is not the one
--expectwas given, whichship.shalways passes - the code was copied up more than
--max-ageminutes ago, an hour by default
Each of those can be overridden with --force, and --dry-run runs every
check and stops before building.
Deliberately redeploying the code already on the box — after an .env change,
say — is the --force case:
ssh bottle@<box> '/srv/bottle/app/deploy/deploy.sh --force'The map
Section titled “The map”deploy/osrm-prepare.sh downloads an extract from Geofabrik and runs OSRM’s
three passes over it. Great Britain takes about an hour and leaves roughly
12 GB behind.
Two things worth knowing. The extract peaks at about 8 GB of memory, which is
tight on an 8 GB box, so the script adds a temporary swap file and takes it
away afterwards. Serving needs about 2 GB, because osrm-routed is told to
read the prepared data from disk rather than load it.
It is not a daily job. Roads do not move much: monthly is generous, quarterly is fine for delivery routing. You can also build the files on your own machine and copy them up, though at 12 GB the upload is usually slower than rebuilding them on the box.
A smaller box can run a smaller map. ./osrm-prepare.sh england or
greater-london build the same way, and moving up later is a reboot.
Nothing is sent from the box itself. A new address on a hosting range has no reputation, and half the messages would land in spam and stay there. Worse, these are not marketing emails: they tell a customer their gas is on its way. Delivery matters.
So: any relay that speaks SMTP, which is the transport the API already has. No gateway SDK, no lock-in, four environment variables.
SES is the cheapest at about 8p per thousand and is worth the half hour
of setup if this is going to run for a while. Resend or Mailgun have
free tiers that cover a test deployment and take five minutes. Either way it
is SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, and port 587 with
STARTTLS demanded whenever there are credentials to protect.
Three DNS records decide whether any of this arrives, and they are not optional:
- SPF, naming the relay as allowed to send for the domain.
- DKIM, the keys the relay gives you, so each message is signed.
- DMARC, starting at
p=nonewith a reporting address, so you can see what is being sent in your name before you tighten it.
Replies go to the business rather than to us. A message that says “reply and
it reaches them” has to be true, so the customer emails carry a Reply-To of
an owner at that business.
Backups
Section titled “Backups”Two kinds, because they answer different questions.
The provider’s snapshots, nightly, seven kept, about €1.36 a month. This is the answer to “the box is gone”. Turn them on in the Hetzner console; there is nothing to install.
deploy/backup.sh, nightly at two, a compressed pg_dump kept fourteen
days locally and copied to object storage with rclone. This is the answer to
“somebody dropped a table at four o’clock”, which the first kind answers
badly: rolling an entire machine back to midnight to recover one table costs
you everything else that happened that day.
Cloudflare R2 gives 10 GB free, which is years of these.
deploy/restore.sh puts one back. It stops the application first, because
restoring underneath a running one gives you half an old database and half a
confused one. Read it before you need it.
An untested backup is a file. Restore one into a scratch box and sign in.
The manual
Section titled “The manual”This site is built into the web app at deploy time and served from /help on
the same origin, so a link from a screen reaches the page that was deployed
with it. Nothing extra to host, and no second domain to keep in step.
On a laptop it runs as its own site on port 3200, and the app points at it
through NEXT_PUBLIC_DOCS_BASE.
Taking payment for distributors
Section titled “Taking payment for distributors”Payment links run through Stripe Connect and need one more secret than
subscriptions do. In the Stripe dashboard add a second webhook endpoint,
listening to events on connected accounts, pointed at
https://<api>/webhooks/stripe/connect with checkout.session.completed,
checkout.session.async_payment_succeeded and account.updated. Its signing
secret goes in STRIPE_CONNECT_WEBHOOK_SECRET. Without it, payment links are
off and the office is told so; everything else stands.
Test keys are refused in production, because a box quietly taking test cards
looks like it is working right up until the month nobody was charged. A
demonstration deployment that means it sets STRIPE_TEST_MODE=true and says so
out loud.
The nightly sweep
Section titled “The nightly sweep”deploy/daily.sh, at half past three, inside the API container that is
already running. It expires keys that are due, warns on trials ending, applies
retention rules, looks up addresses still without coordinates, warns on
vehicles due at the garage, and writes the orders due from every standing
arrangement.
30 3 * * * /srv/bottle/app/deploy/daily.sh >> /srv/bottle/daily.log 2>&1After the backup at two, so a bad night is recoverable, and before anybody plans a round. Everything it does is idempotent, so a missed night is caught up by the next one and running it twice changes nothing. Without it, regular deliveries never become orders.
What is shut
Section titled “What is shut”deploy/bootstrap.sh does this, and it is worth knowing what it did.
- Firewall default deny. Open: 22, 80, 443. Nothing else, and in particular not Postgres and not OSRM.
- SSH by key only. No passwords, no root login.
- Automatic security updates, because the point of a box nobody logs into for weeks is that it patches itself.
- fail2ban on SSH.
- Containers as a non-root user, and the application images carry no package manager, no compiler and no source.
- Cloudflare in front, so the origin can be locked to Cloudflare’s ranges once DNS is settled and the box stops being reachable by address at all.
Secrets live in /srv/bottle/.env, owned by the deploy user, mode 600, and
never in the repository.
The queue
Section titled “The queue”Emails are queued rather than sent inside the request that caused them. Sending a round out meant twenty-seven SMTP conversations before the driver’s button came back: twenty-one seconds, measured on the real box. It is now under three tenths of a second, and the emails go out behind it.
pg-boss keeps the jobs in the Postgres
already running, in its own jobs schema. That is the whole reason it was
chosen over anything Redis-backed: no second container, nothing else to
patch, and no memory taken from the routing engine. A queued job is a row, so
it survives a deployment, and a failure is retried five times with backoff
rather than lost.
The worker runs inside the API process. One box, one API container: a separate worker would be a second thing to deploy for no benefit today. When there are two API containers it moves out, and nothing above it changes.
On PGlite there is no queue at all: jobs run inline, in the request, which is
what a laptop wants and what the tests assert against. The switch is which
DATABASE_URL is in use, and nothing in the application knows the difference.
The box builds its own images, and Buildkit keeps every layer it has ever
built. Left alone that filled 75 GB in a day of deployments and the next build
failed with no space left on device. deploy.sh now prunes the cache to five
gigabytes after each build, which is enough to keep builds fast.
Worth knowing what is actually big: the prepared routing data is 5 GB and
never changes, images are about 2.5 GB, and the rest is cache. If a deploy
ever fails oddly, df -h first.
What this does not do
Section titled “What this does not do”No CI, no staging, no zero-downtime deploys: deploy.sh restarts the
containers and the app is away for a couple of seconds. No read replica, no
connection pooler, no object storage for delivery photographs, which are
base64 in Postgres until there is a bucket.
All of that is the right thing to add when somebody is paying. None of it is the right thing to add first.