There's a picture that makes the rounds among engineers: on the left, "full-stack in a demo", two friendly boxes, Frontend and Backend. On the right, "full-stack in production", a teetering stack of thirteen rings: frontend, APIs, database, auth, hosting, compute, CI/CD, security, rate limiting, caching, load balancing, observability, and availability. The joke lands because it's true. The distance between "it runs on my laptop" and "strangers can rely on it at 3 a.m." is exactly those extra eleven layers.

This is the log of closing that distance for Feed Me Data, a mobile-first tech/AI news aggregator, as a solo project. I wrote it as I went, from August 25 to September 1, mistakes included, because the mistakes are the useful part. It's finished now: all thirteen rings are standing, and the one that started red is green.

The gap between "it runs" and "it's in production"

Feed Me Data works beautifully in development. It's a server-rendered app with a local database, a set of scrapers that pull in articles, videos, podcasts, and jobs, and a scheduler that keeps everything fresh. On my machine, one command is the whole story.

Production asks harder questions. Where does it run when my laptop is closed? What happens when the disk fills, the process crashes, or a cert expires? How do I ship a change without babysitting it? Where do the backups live, and have I ever actually restored one? Each of those is one of the thirteen rings, and none of them are interesting until they're on fire.

What we're deploying (and why it can't be "serverless")

The most important early realization was about shape. Feed Me Data is a stateful monolith:

  • a long-running server (not a stateless function),
  • a database that lives as a single file on disk,
  • scrapers that shell out to a real browser as child processes, and
  • a scheduler that runs inside the app process on a timer.

That shape quietly rules out the fashionable answers. The trendy edge and serverless runtimes fail this shape four times over: they can't load the native database module, they have no persistent filesystem, they can't spawn a browser subprocess, and they scale to zero, which would kill the in-process scheduler. I spent real time pricing out the edge-native path, and the honest conclusion was: running the app there isn't a migration, it's a rewrite into a different application, for no user-facing benefit.

So the architecture is deliberately, almost defiantly boring: one small always-on VPS runs the whole thing, with a CDN/edge in front for TLS, caching, and DDoS protection, and a tunnel so the origin has no open ports and its address stays private. Boring infrastructure is a feature. It's cheap, it's understandable, and I can move it to another host in an afternoon.

Write the decision down: the ADR habit

Before any of it got built, each non-obvious choice got a paragraph of its own, an Architecture Decision Record. An ADR is a tiny document with four parts: the context (what forced the choice), the options weighed, the decision, and the consequences you're accepting. That's it. No template ceremony; just enough to answer "why on earth did we do it this way?" six months later.

The counterintuitive part: a solo project needs this more than a team does, not less. Nobody else remembers the reasoning, because no colleague heard it and no design review captured it. What remains is future-you, who is a stranger, standing at midnight about to re-argue a settled question because the argument was never written down. By the end there were about a dozen of these records, and more than once the useful move was simply re-reading one instead of relitigating it.

A few of them, generalized:

  • Host & compute: one small VPS, running everything, including the scrapers. Scale up, not out. The single-writer database makes that the only honest option.
  • Database: keep the file-on-disk database for now. Nothing in the managed-database menu beats a file on a volume until multi-host pressure forces the issue.
  • Backups: stream the database's write-ahead log continuously to object storage, with a fixed retention window and restore-on-boot, so a fresh host rebuilds itself from the last good state. (Backups you haven't restored aren't backups.)
  • Edge: use the CDN for what it's genuinely good at (caching static assets, TLS, a tunnel, and free baseline bot/rate-limiting), and not as a place to run the app.
  • Auth: stay on a managed auth provider. As a solo founder, paying someone else to be the security team for logins is money well spent.

One note on what's not here: the full record, with the real trade-offs and the vendors actually chosen, lives in the private repo. A public post is the wrong place to publish your exact attack surface.

The build-test that earned its keep

Here's the part worth the price of admission.

Before wiring up any servers, I containerized the app and built the image locally, the cheapest possible dress rehearsal. My prediction was that it would stumble on the heavyweight scraper dependencies (a headless browser, a few finicky packages). Those installed without complaint.

It failed somewhere I didn't expect: the front-end build step, with a terse message that a public configuration value "is not exported" by the framework's build-time environment module.

The cause is a subtle, common assumption. Frameworks like this one inline certain environment variables at build time: they bake the values directly into the compiled output. On a laptop, a local .env file quietly supplies them, so you never notice. A clean container build has no such file, so the build stops cold.

That was easy enough to fix by passing the values in explicitly. But it exposed a second, more important problem. A handful of private API keys were being pulled in through the same build-time mechanism, which meant their values would be compiled straight into the image. Anyone who could pull that image could read the keys out of it. The build tool's own linter said so, in as many words:

Do not use ARG or ENV instructions for sensitive data

The clean fix was to change how those keys are read, from the build-time mechanism that inlines values to the runtime one that reads them from the running container's environment and never touches the build. Public values (the ones that ship to the browser anyway) stay build-time; private secrets move to runtime and stay out of the image entirely.

The lesson generalizes: containerizing an app surfaces every hidden assumption it ever made about its environment. That feels like the container being difficult. It's actually the container being honest: the app was always making those assumptions; a laptop was just polite enough to hide them. Better to learn it from a local build than from a leaked image.

Standing it up: provisioning and the first deploy

The boring stack came up about as excitingly as boring should: a hardened host, the edge and tunnel in front, the backup stream pointed at object storage, secrets placed out of source control. The pipeline is the part I wanted to be dull for the rest of its life: merge to the main branch → the CI service builds the image → ships it to the host → the host pulls it, swaps the container, and health-checks itself before declaring the deploy a success. Nobody has to log in, and nothing waits for a human at 2 a.m.

The first deploy also settled the backup question the only way that counts. A fresh host, given nothing but the credentials, rebuilt the entire database from the streamed write-ahead log on boot and came up serving. That's the difference between having backups and having recovery, and it's a difference you only actually know after you've watched a bare machine reconstitute itself.

The thirteen layers, resolved

Here's the payoff, the same thirteen rings, at rest:

  • 1–2 · Frontend & APIs: the app itself, server-rendered, security-hardened.
  • 3 · Database & storage: a file on a volume; the managed-database question stays deferred until it earns the complexity.
  • 4 · Auth: a managed provider, with an admin guard on privileged routes.
  • 5–6 · Hosting & compute: one always-on VPS; scale up, not out.
  • 7 · CI/CD: the self-healing merge-to-live pipeline above, plus a guard I'll come back to.
  • 8 · Security: a full audit, then edge hardening: forced HTTPS, a strict transport policy, long-lived caching for immutable assets, baseline rate limits on the sensitive routes, a managed rule set, a bot filter, and a challenge on the login form.
  • 9–10 · Rate limiting & caching: handled at the edge, with an in-app cache for the expensive aggregates.
  • 11 · Load balancing: deliberately not applicable. A single-writer database makes a single instance mandatory; "N/A" here is a decision, not an oversight.
  • 12 · Observability: the ring that started red, because the app had no health endpoint and nothing outside it was watching. It now exposes a health probe (which also reports whether ingestion is still flowing), writes structured logs, and is watched from outside by an uptime monitor wired to real alerts.
  • 13 · Availability & recovery: continuous backups, restore-on-boot, one-command rollback to the previous image, and a tested cold-restore drill.

The one red cell on the original board is green. That felt disproportionately good.

Four things that broke the way production breaks

Everything above makes it sound tidy. It wasn't. Production breaks in a particular register: quietly, and usually in the seam between two things that each worked fine alone.

The disk that filled. Each deploy leaves a tagged image behind, and the routine image cleanup deliberately never deletes tagged images. They piled up until the volume hit 100%, at which point the database, starved of space for its temporary files, threw a disk-I/O error and the site started returning 502s. The fix is a one-liner: keep the last two images for instant rollback, delete the rest. The lesson is bigger: know exactly what your cleanup does and does not reclaim before you trust it to run unattended.

The deploy that went backward. Two merges landed close together. Their test runs finished out of order, and since each deploy ships its own commit, the slower test, on the earlier commit, finished last, and its deploy quietly overwrote the newer one. The site was serving older code, with a green "deploy succeeded" next to it. The fix is a monotonic high-water mark: the host refuses to deploy a commit older than the one already running. The lesson: "the deploy succeeded" is not "the right code is live."

The health check that cried wolf. The freshness signal keyed off the timestamp of the last article inserted. On a quiet news night (the pipeline running perfectly, just finding nothing new to insert), that timestamp goes stale, and the monitor pages you for an outage that isn't happening. The fix was to key the signal off the last fetch (did the job run?) rather than the last insert (did it find news?). The lesson: measure the thing you actually care about, not the side effect it usually produces.

The status page that shared its fate. The reflex is to add a /status route to the app. But a status page that lives on the same box goes dark exactly when the box does, useless in the one moment it exists for. So it moved onto independent edge infrastructure, fed by an outside-in monitor, where it stays up and tells the truth during an outage. The lesson: a status page must not depend on the thing it reports on.

Lessons

  • Boring infrastructure is a feature. Match the deployment to the app's real shape; don't chase a runtime that demands a rewrite.
  • Write the decisions down. A short record (context, options, decision, consequences) keeps a solo project honest and stops you re-arguing settled questions at midnight.
  • Build-test before you deploy. It's the cheapest place to discover the assumptions your app has been quietly making.
  • Never bake secrets into an image. Read them at runtime; keep only truly-public values at build time.
  • Test the restore, not just the backup. A backup you've never restored is a hope, not a plan.
  • Know what your cleanup reclaims. The disk fills in the gap between "it prunes images" and "it prunes those images."
  • "Deployed" isn't "live." Guard against shipping the wrong commit, not just a failed one.
  • Measure the signal, not its side effect. Or you'll page yourself on a quiet Tuesday.
  • Don't let the status page share fate with the site. Independence is the entire point of it.

The distance between the two-box demo and the thirteen-ring reality is real, but it's finite. Close it ring by ring, write down why you did each one, and let the mistakes teach the parts the diagrams never do. Then the eleven extra layers get to be what they should have been all along: boring.