pouyakarimi.ir — off serverless, onto a server I run
This site's hosting and database moved off Vercel and Neon onto a Frankfurt VPS I administer. Median time to first byte on the Persian homepage: 1,318 ms to 568 ms, measured over ten rounds on each side.
- Client
- Own site — pouyakarimi.ir
- Scope
- Hosting and database migration, serverless → self-managed
- Role
- Solo — architecture, CI, cutover, rollback
- Year
- 2026
This site used to run on Vercel, with its database in Neon's us-east-1 region. It now runs in a container on a Frankfurt VPS I administer, with PostgreSQL on the same box. The Persian homepage's median time to first byte went from 1,318 ms to 568 ms. You are reading this on the result.
The Problem
Most of this site's readers are in Iran. The origin was serverless and the database was on the other side of the Atlantic, so every page assembled itself across a transatlantic round trip per query — and a page that renders a book listing makes several.
That is a guess until it is measured, so I measured it first: ten rounds against the live origin, before touching anything. The Persian homepage came back at a median of 1,318 ms to first byte, with a 95th percentile of 3,133 ms. The measurement is committed to the repository next to the one taken afterwards, so the comparison is a diff rather than a memory.
The second problem was structural rather than numeric. Vercel's CLI and dashboard are unreachable from Iran; deploys only worked because GitHub sits in the middle of the path. Anything that needed the platform directly — reading a build log, setting an environment variable, checking whether a deploy had even happened — needed someone else's network. A production system you cannot inspect from where you are is one you do not really operate.
The constraint was the target. The box already ran other production services: Maaleto, my own product, with real user accounts and financial records in its database; an n8n automation stack; a VPN. Two cores, and already in swap. Nothing about this migration was allowed to disturb any of it.
The Solution
The move was split into two independently reversible steps rather than one: relocate the hosting while the database stayed remote, then flip the database a day later. Each step had its own rollback, and the day in between was a real observation window rather than a formality.
- Images are built in CI and pulled from a registry, never built on the box. A Next.js build peaks at 2–3 GB of RSS against roughly 2.4 GB free, and would have pinned both cores away from the neighbours for the length of every deploy.
- The deploy is a single SSH connection. The first version used a file-transfer action followed by a command action and died with a network timeout — the host firewall drops any address that opens six connections in thirty seconds, and the transfer alone opens several. The compose file now travels inside the one connection as base64.
- PostgreSQL runs in the same compose project with no published ports at all, tuned for a two-core box that is already swapping: 192 MB of shared buffers, 20 max connections against an application pool of 10.
- Caddy terminates TLS and issues certificates over
tls-alpn-01— six seconds for the validation hostname, twelve for the apex andwwwafter cutover. - The nightly backup dumps this database too, into the encrypted off-site archive the box already produced for its other services.
- The scheduled jobs became systemd timers, with an explicit
UTCsuffix on each schedule.
Design Decisions
Validate on a real hostname, not a --resolve flag. The plan called for pointing curl at the new IP before touching DNS. That turned out to be impossible rather than merely awkward: this Caddy build ships no DNS-provider module, so DNS-01 is unavailable and no certificate can exist for a hostname whose DNS does not already point at the box — every request would have failed on a name mismatch. A real temporary subdomain was used instead, which was the better test anyway: it exercised genuine ACME issuance, the one step the original plan would have left untested until the moment it mattered.
Restore from a dump, not from the migration history. The repository carries four recorded Prisma migrations and nineteen hand-written SQL files, because the database's own port is filtered from this network and schema changes have been going through a console. Prisma's recorded history therefore cannot reconstruct the real schema — a migrate-based rebuild produces a database that is subtly wrong rather than one that fails loudly. The restore was verified with a schema diff, not row counts: counts cannot see a missing index, constraint, default or sequence.
Match the collation before any data moves. I had configured the new PostgreSQL as en_US.utf8, assuming the managed source used a glibc locale. Asking it directly returned C.UTF-8. Collation is compiled into every text index and decides the result order of every ORDER BY on a text column, so a mismatch silently reorders every Persian book and article listing and reports nothing at all — no error, no warning, and no test that would catch it. The volume was recreated before a single row went in.
Fix the deploy gap in the proxy, not the orchestration. There is one application container, so a deploy stops it before its replacement accepts connections — one to two seconds during which nothing is listening. Vercel's deployments are atomic; a single-replica container swap is not, and nothing in the plan had anticipated the difference. It surfaced as fourteen 502s inside a two-second window, all from one real visitor, hours after a watch window had reported everything clean. The fix was to let Caddy retry for a bounded ten seconds, so a deploy costs one slow request instead of a broken page — bounded deliberately, because a genuine outage should delay the error rather than hang every browser. Verified by driving thirty concurrent requests through a forced container recreation: thirty 200s, no failures.
Write the rollback before the one-way door. The database flip is the moment writes stop existing in two places. The script that dumps everything back the other way was written before the flip rather than during an incident, and it refuses to read its destination from the environment variable that — after the flip — names the source, because reading it would restore the database over itself. It also does not touch DNS: the records are only reverted once the row counts on both sides match.
An error handler that hides the error is a bug. One analytics path ended in a bare catch {}. That is the right decision about the response — a logging outage must never change what a visitor receives — but it made a total outage indistinguishable from "nothing happened", and cost two days of plausible, wrong hypotheses. Adding one line that printed the underlying cause named the problem on the very next request: TLS being spoken to a plaintext port, because the proxy sets X-Forwarded-Proto: https and the application was deriving an internal URL from the incoming request. Failing silently for the user and failing silently for the operator are two different decisions, and they had been made as one.
The Outcome
| Path | Before (p50) | After (p50) | Change |
|---|---|---|---|
/fa | 1,318 ms | 568 ms | −57% |
/fa/blog | 681 ms | 176 ms | −74% |
/fa/books | 520 ms | 213 ms | −59% |
Ten rounds per path, on both sides, zero errors. The 95th percentile on the Persian homepage fell from 3,133 ms to 972 ms, which matters more than the median: the slowest requests are the ones a visitor abandons.
- No platform account sits on the critical path. Deploys, logs, environment variables and the database are all reachable from where the work happens.
- Backups are verified restorable, not merely present — checked by decrypting the archive and diffing it against the live database, table by table, rather than by confirming a file exists.
- The neighbours were untouched. No restarts, no resource contention, and their own backups unaffected throughout.
- The rollback stayed real for the whole window: two reversible steps, a dump-back script written in advance, and the previous DNS zone left in place.
Tech Stack
- Application: Next.js 16 (App Router) in standalone output, Node 22 on Alpine, non-root, multi-stage build
- Orchestration: Docker Compose with an explicit project name, published only on loopback
- Database: PostgreSQL 17 on the compose bridge, no published ports,
C.UTF-8 - Edge: Caddy — TLS via
tls-alpn-01, bounded retries across deploy gaps, real client IP forwarded - CI/CD: GitHub Actions builds and pushes to GHCR; the deploy job is one SSH connection
- Scheduling: systemd timers with explicit UTC calendars
- Backups: nightly
pg_dumpinto an existing encrypted off-site archive, byte-verified after upload - Measurement: a committed TTFB harness, ten rounds per path, run before and after