The API is live, and deploys itself
The API's hosting decision is made: a free-tier GCP VM, Neon and Upstash, zero-downtime rolling updates on a single-node Swarm - and why the VM pulls its own updates instead of GitHub Actions pushing them.
Update, 11 October 2026: the API no longer runs on the setup described here. It moved to FastAPI Cloud after the VM’s public IP turned out not to be free, and the worker now runs from a scheduled GitHub Actions workflow. This post is left as it was written.
The last status report left one thing
unresolved: where the API runs. That’s decided now, and live at
api.pacestreak.com.
The stack
A GCP e2-micro - the Always Free tier’s one instance - running the API and
worker in Docker containers. Postgres and Redis are not containers on that
box: an e2-micro has about 1GB of RAM, and a database, a cache, the API and
the worker all fighting over that left no headroom for any of them. Postgres
is Neon (serverless, scales to zero); Redis is
Upstash (rediss://, TLS). Photos moved to Cloudflare
R2 a while back for the same reason a Postgres LargeBinary column was always
going to be wrong for this - see the R2 migration
if you want that story.
Ingress is a Cloudflare Tunnel, not a reverse proxy with an open port.
cloudflared runs as a container next to the API, reachable only by service
name on the overlay network - nothing binds to the VM’s network interface at
all. dig api.pacestreak.com resolves to Cloudflare’s anycast IPs like every
other hostname on this domain; there is no public inbound port anywhere on
that machine.
Zero-downtime, on a single VM
One instance sounds like it should mean “briefly down on every deploy.” It
doesn’t, because the VM runs a single-node Docker Swarm rather than plain
Compose. Swarm’s rolling update starts the new container, waits for its
healthcheck to pass, and only then stops the old one - the overlay network’s
routing mesh never sends traffic to a task that hasn’t passed its healthcheck.
cloudflared never sees a gap.
The part we got wrong first, and un-wrote
The first plan had GitHub Actions build the image, then SSH into the VM over a key that could run nothing but one forced deploy script, and update the running service itself. It’s a defensible design - a leaked key can’t open a shell, only run that one command - and it’s what a lot of “zero-downtime CI deploy” writeups describe.
It’s also a second thing to get right for no real benefit here: this org already keeps no deploy tokens in any other repository (Cloudflare Pages builds are Git-connected, not Actions-triggered), and adding one just for this VM would be the only exception. So the actual answer is simpler: GitHub Actions only builds and pushes the image. There is no credential in this org’s Actions secrets that can reach the VM at all, in either direction.
The VM decides for itself, on a two-minute systemd timer: pull
ghcr.io/pacestreak/api:latest, compare its digest against what’s currently
running, and roll out the difference with the same start-first Swarm update
described above. Pinned to the resolved digest rather than the mutable
:latest tag, because Swarm only re-checks an image reference when the
reference itself changes - re-applying :latest verbatim on a later restart
would silently do nothing. The unit files live in the repo
(api/deploy/gcp/), not just on the machine, so what’s running is what’s
reviewable.
The trade That’s not free: a change takes up to two minutes to reach production instead of landing the second CI finishes, and pull-based means there’s no single event to point at and say “that’s when it deployed” - you read a log line instead of a CI run turning green. For a one-person, one-VM project neither of those costs anything real, and the credential that disappears is one this project would otherwise be carrying purely so a deploy could happen a few minutes sooner.
Verification and reset move to codes
One more change landed alongside the deploy work: email verification, password reset and email-address changes now email a 6-digit code instead of a clickable link. A link only works if you open it from the same device or browser context you’re signed in on, or aren’t signed in yet, and it breaks in exactly the situations people actually hit it in - reading a signup email on a phone while signing up on a laptop, a mail client that pre-fetches links and silently spends them, a corporate scanner that clicks every URL in every inbound email before a person ever sees it. A code you read and type has none of those failure modes.
The code is checked differently than the token it replaces, on purpose: a
32-byte link token is unique enough to look up by itself, but a 6-digit code
is not - two people can get 042817 on the same day, and the database has to
expect that rather than pretend it can’t happen. Lookup is by account and
purpose first, the code checked against that row second, and five wrong
guesses invalidate it - a deliberately short leash for something with this
little entropy compared to what it replaced.
What’s still open
Same list as before, minus the item this post just closed. Update:
app.pacestreak.com has since been given its own custom domain and is live,
still noindex - it’s the product, not something search should surface before
there’s a public launch to point at. Backups are configured but the restore
path wants another real test. And the credentials that got typed into a chat
window while setting any of this up are being treated as already compromised
and rotated, on the assumption that “probably fine” is not a security posture.
AlzyWelzy
Building PaceStreak. GitHub
Related
InfrastructureOne outage, four root causes
The API went down behind a Cloudflare Tunnel error and stayed down through a reboot, an interrupted OS upgrade, a stale connection pool, and a box too small for its own workload. None of the four were the same bug.
9 min
Security & PrivacyFixing the dead ends in sign-up and verification
Three real bugs found by using the OTP flow the way a person actually would: a stuck login, a false 'already registered' error, and a confirmation screen that didn't lead anywhere. Plus a status page that finally watches the whole stack.
3 min
Security & PrivacyNotification buttons without a session
A habit reminder now has Done and Snooze buttons. The service worker that handles them has no access token, by design, so each button carries a signed, single-purpose link instead.
2 min