Infrastructure · Build Notes · Blueprint
The Stateless Storefront
By the end you'll understand how a website that takes a payment and hands back a watermarked file is wired — every component, why it's there, how the pieces talk, and the exact places it breaks — built so you can move the whole thing to a different server in an afternoon.
00Why this matters
I run a small digital-product shop on a Hostinger VPS, and this is the architecture I keep coming back to — not because it's the only way, but because every time I cut a corner here, the corner came back to bite me. So this is the direction I take now, written down: the wiring, the reasoning, and the specific things that broke on my own box. — A.R.
You can stand up a storefront in a weekend. The hard part isn't getting the first sale — it's the three things that happen after.
One: a customer pays, the network hiccups, and your code never hears about it — so they're charged and get nothing. Two: someone finds the raw URL of the file you sell and shares it in a Discord; now it's free forever. Three: your host raises prices, or goes down for a day, and moving to another server means a frantic night of hoping you remembered where everything was.
Every one of those is an infrastructure problem, not a feature problem. Get the wiring right and they quietly disappear: the payment becomes a fact your server can't miss, the file becomes impossible to share, and the move becomes four commands. This is the wiring.
01First principles — building the vocabulary
Strip away the brand names and a pay-and-download site is four verbs: show a page, take money, prove the money arrived, hand back a file that's stamped with who bought it. Hold those four in your head; every component below exists to do one of them safely.
A few terms, defined the first time you meet them:
- State — anything the system must remember between requests. Who bought what. How many downloads they have left. The bytes of a PDF. Lose state and the business is gone; everything else is just code, which you can always rebuild from a repo.
- Stateless process — a running program that keeps no important memory of its own. Ask it the same question twice and it answers from somewhere external both times. The opposite, a stateful process, hides data inside itself — and the moment it dies, that data dies with it.
- Backing service — a thing your app talks to over the network and treats as a plug-in resource: a database, a file store, a payment provider. The app shouldn't care which one, only that it answers. Swappable by changing a setting, not the code.
- Reverse proxy — the doorman. One process that every request hits first; it checks TLS, decides which internal service to forward to, and can slam the door on abusive traffic before your app ever wakes up.
- Container — your app plus everything it needs to run, frozen into one image. Run that image on any machine with Docker and it behaves identically. The unit of "ship it somewhere else."
The whole design is one rule applied everywhere: push all state out of the app, into backing services. The app holds nothing, so it's disposable. This idea isn't new — it's the spine of the twelve-factor method that modern hosting is built on. We're just applying it to a shop.
02The unlocks — three things people get backwards
Your server is not where your data lives. It's where your code runs. Beginners picture the server as a filing cabinet — open it and the orders are inside. Flip it: the server is a worker who reads from a filing cabinet down the hall. Move the worker to a new desk and the cabinet doesn't move; you just tell the new worker which hall.
The webhook, not the redirect, is the truth. After payment, Stripe sends the buyer back to your "thanks" page — and separately, server-to-server, sends a signed message saying "this really happened." The redirect can be faked, refreshed, or skipped entirely by a clever URL. The signed webhook can't. Grant the download off the webhook, never off the page the browser landed on.
A watermark isn't a property of the file you sell. It's something you do, once, to a copy, at the instant of download. You never store a "watermarked PDF." You store one private master, and every download is a fresh stamped copy made on the fly. That's why the same product can carry a thousand different buyers' names without you keeping a thousand files.
03The stack, layer by layer
Ten components. Read the stack diagram once for the shape, then take each part in turn. The mechanisms that actually carry risk — payments, the download gate — get the full what it is / how it works / what breaks it treatment. The rest get the short version, because they're plumbing and plumbing should be boring.
layer · appNext.js + Payload, unified
One application is the front end (the shop), the back end (the API routes that take payments and serve files), and the admin panel (where you add products), all in a single codebase and a single deploy. Payload installs into the Next.js app rather than sitting beside it as a separate server. Why it earns the top slot: one thing to build, one thing to deploy, one place to reason about. The maintenance win is the absence of a second moving part.
layer · dataThe database
Holds the durable records: products, prices, orders, and — the part that matters most — entitlements: a row per purchase recording who, what, how many downloads remain, and when access expires. Start on SQLite (a single file, nothing to administer); graduate to Postgres when concurrent writes get heavy. Because the app talks to the database through one abstraction, swapping engines later changes a config adapter and a migration, not your business logic.
layer · storageObject storage — the part people forget
You actually need three separate stores, and keeping them apart is what makes the download system secure rather than theatrical:
- Master PDFs — the clean originals you sell. Private. Never reachable by a URL.
- Watermarked output — generated on demand, streamed once, ideally never written to disk at all.
- Site media — images and uploads the admin panel manages.
Use S3-compatible storage — Cloudflare R2 (managed) or self-hosted MinIO — not the container's local disk. This is your single biggest portability lever: the files live outside the server, so a rebuild or a full host move loses nothing.
Put the SQLite file or your uploads on the container's own disk with no mounted volume, and they look fine — until the next redeploy wipes the container and takes them with it. Containers are designed to be thrown away. Anything you can't afford to lose belongs in a volume or an external store.
layer · payment · core mechanismStripe
A hosted checkout page Stripe runs, plus a server-to-server message it sends you when a payment clears. The buyer leaves your site to pay, then comes back.
Your checkout route asks Stripe to create a session (with your product, price, and a bit of metadata naming the buyer and item), then redirects the browser to Stripe's page. Card details are typed on Stripe's domain. When the charge succeeds, Stripe POSTs a checkout.session.completed event to your webhook route, signed with a secret only you and Stripe share. Because the card never touches your server, you fall under the lightest compliance tier — the short SAQ-A questionnaire — instead of the heavyweight audit you'd owe if you handled raw card numbers yourself.
Stripe may send the same event more than once, possibly at the same time — so fulfillment must be idempotent: do it once per session, no matter how many times you're called, or one purchase grants two sets of downloads. Signature checks need the raw request body; a framework that auto-parses JSON first will silently break verification. And test-mode vs live-mode secrets don't interchange — mixing them is the classic "works on my machine, 400s in production" bug.
layer · download · core mechanismThe entitlement & watermark gate
The guarded door between "paid" and "got the file." It checks that a download is allowed, stamps the buyer's identity onto a copy, and streams it — then quietly closes a little more each time.
When the webhook confirms payment, you write an entitlement: uses-left (say, 3) and an expiry (say, 24 hours out). You hand the buyer a signed token — a tamper-proof string that encodes which entitlement it unlocks. On download, the download route verifies the token, checks the entitlement is alive, decrements the count, pulls the master from private storage, runs pdf-lib to draw the buyer's name and email onto a copy, and streams the bytes. The master is never sent directly. pdf-lib is pure JavaScript and would run anywhere; the route runs on the Node runtime because it needs to reach the database and stream a file, not because pdf-lib does.
If the token never expires and isn't use-limited, one buyer's link becomes everyone's link. If you validate and decrement in the wrong order — or non-atomically — a burst of parallel requests can each see "uses-left: 1" and all succeed. If the master is reachable by its own URL, the whole gate is decoration. And if you stamp identity onto a stored shared file instead of a per-request copy, you've leaked the last buyer's details to the next one.
layer · edgeTraefik — reverse proxy & TLS
The doorman. It routes each subdomain to the right container, and it auto-issues and auto-renews TLS certificates from Let's Encrypt — 90-day certs, renewed without you touching them. Its middleware chain gives you an IP allowlist, rate limiting, and basic-auth on /admin as configuration, not code. The config is just files, so it travels with the stack.
layer · packagingContainerization — the portability backbone
Everything above is described in one docker-compose.yml: the app, the proxy, the object store, wired together with named volumes for the bits that must persist. Moving servers becomes: clone the repo, drop in the .env, restore the volumes, up. That sentence is the migration.
layer · configSecrets & configuration
Every key — Stripe secrets, the app secret, storage credentials, SMTP — lives in .env, never in git. This is both a security rule and a portability rule: the same container image runs anywhere, and its behaviour is set entirely by the environment it's dropped into. Code is public-shaped; secrets are environment-shaped; never let them mix.
layer · email + backupsThe quiet two
Transactional email (Resend, Postmark, SES, or plain SMTP) sends the receipt and download link. Optional, ordinary, but expected. Backups — a scheduled database dump plus an object-store snapshot — are what turn "portable" into "portable and safe." Portability without backups just means you can move quickly to a server that also has no data.
04The request flow — pay to file
This is the diagram to screenshot. Eleven steps from a click to a stamped download, with the one band that matters most marked: the stretch where the master file is in play and must never escape.
05A real purchase, traced end to end
Priya Sharma, an embedded engineer in Pune, buys the CAN Bus Field Guide for $29. Here's every hop, with real values.
- Priya clicks Buy. The checkout route calls Stripe to create a session for price price_1Q8…, attaching
metadata: { product: "can-bus-field-guide", email: "priya@…" }. - Her browser is redirected to checkout.stripe.com/c/pay/cs_live_…. Your server has not seen a card number, and never will.
- She pays. Stripe shows its own success page, then bounces her back to
store.ajaitookit.cc/thanks. You do not trust this page. - Separately, Stripe POSTs
checkout.session.completedto/api/webhooks/stripe. Your handler reads the raw body, verifies theStripe-Signatureagainst your webhook secret. Valid. - The handler checks it hasn't already processed cs_live_… (idempotency), then writes an entitlement:
{ email, product, usesLeft: 3, expiresAt: now+24h }. - It mints a signed token eyJ…stamp bound to that entitlement, and emails Priya a link:
…/download?t=eyJ…. - Priya opens the link. The download route verifies the token's signature — untampered — and loads the entitlement: alive,
usesLeft: 3. - In one atomic update it sets
usesLeft: 2. (Do this before serving, so a crash mid-stream can't be retried into a free extra download.) - It fetches the master can-bus-field-guide.pdf from the private bucket — a path no public route maps to.
- pdf-lib draws, on a copy, a footer on every page: “Licensed to Priya Sharma · priya@… · do not distribute.”
- The stamped bytes stream to her browser. Nothing was written to disk; the master is untouched; the next buyer will get their own name, not Priya's.
Three things to notice. The card never entered your world. The grant hung entirely on step 4's signature, not step 3's redirect. And the file she holds is hers alone — traceable, single-purpose, made the instant she asked for it.
06Trade-offs — the choices that have two right answers
| Decision | Pick the first when… | Pick the second when… |
|---|---|---|
| SQLite · vs · Postgres | Low write concurrency, you want zero DB admin and a single portable file. | Many simultaneous writes, or you'll add live dashboards/telemetry. This is the genuine fork — switch the day concurrency bites. |
| Cloudflare R2 · vs · MinIO | You want managed, zero-ops, generous egress, off your box by default. | You want everything self-hosted and S3-API on your own metal, and you'll run the backups. |
| Hosted Checkout · vs · custom form | Almost always — minimal PCI scope, card never touches you. (See Fig. 04.) | Effectively never, for a shop this size. The cost is real and the benefit imaginary. |
| Node runtime · vs · edge | The download route — it needs DB access and file streaming. | Static or lightweight pages with no Node-only needs. pdf-lib runs in either; the route's other work decides. |
Start on SQLite and don't apologize for it. The instinct to reach for Postgres "to be safe" buys you operational weight you won't use for months. Because the data layer is swappable, the cost of being wrong is one migration — cheap. The cost of premature Postgres is paid every single day in complexity. Defer it until a real number forces your hand.
07Where it breaks — the Level-3 teardown
This is the part to keep. Every item below is a real way a working version of this site quietly fails, and the rule that prevents it.
| Failure | What actually happens | The rule that prevents it |
|---|---|---|
| Trusting the redirect | Someone hits /thanks directly, or refreshes it, and your code grants a download with no payment. | Grant only on the verified webhook. The success page is cosmetic. |
| Duplicate webhooks | Stripe re-sends an event; you fulfil twice; one purchase yields six downloads. | Record processed session IDs; fulfil once per session, idempotently. |
| Mangled raw body | A JSON body-parser rewrites the request before verification; every signature check fails in production. | Feed signature verification the untouched raw body. |
| Race on uses-left | Three parallel downloads each read “1 left” and all succeed. | Validate and decrement in one atomic DB operation, before streaming. |
| Public master URL | The master PDF is reachable by its own link; the whole gate is bypassed. | Keep masters in a private bucket no route maps to; serve only via the gate. |
| Immortal token | A non-expiring, unlimited link gets pasted into a forum; the product is now free. | Sign the token, bind it to an entitlement, expire it, limit its uses. |
| State on the container | SQLite file / uploads live on container disk; the next deploy erases them. | Mount a volume or use external storage for anything you can't lose. |
Lost acme.json | Cert storage isn't persisted; on restart Traefik re-requests certs and hits Let's Encrypt's weekly rate limit. | Persist and back up the ACME storage file; use staging while testing. |
| Secrets in git | A pushed .env leaks live Stripe keys to anyone who clones. | .env in .gitignore; secrets only in the environment. |
| Webhook timeout | Heavy work in the handler exceeds Stripe's ~20s window; the event is marked failed and retried. | Acknowledge fast (2xx), do slow work asynchronously. |
08Portability — what "moving servers" actually is
Here's the payoff of every decision above. Because the app holds nothing, switching hosts isn't a rebuild — it's relocating three things and redeploying a container that runs anywhere. On a Docker-based VPS (which is what a Hostinger VPS is — a plain Ubuntu box), the host brand is irrelevant. Your DNS sits at Cloudflare, so even the address isn't tied to the host: repoint one record and you're live.
Hostinger's VPS snapshots are a fine safety net, but they're a proprietary format — restoring depends on Hostinger's tooling. Keep your own docker-compose.yml plus volume backups so your escape hatch doesn't belong to your host. Portability you can't exercise without the incumbent's blessing isn't portability.
✎Field notes — my Hostinger install, and what bit me
The diagrams above are the clean version. Here's the messier truth of getting it onto a real box.
The setup. One Hostinger VPS on Ubuntu 24.04, everything in Docker behind Traefik at the edge. DNS lives at Cloudflare. I run two domains — ajaitoolkit.cc for my own infra and tooling, and ajaitookit.cc for the shop, with the storefront at store.ajaitookit.cc. It's one compose project: Traefik issues the certs, each service announces itself with a label, Cloudflare points at the box.
How it went up, roughly. Provision the VPS → install Docker → bring up Traefik with a Let's Encrypt resolver and a persisted acme.json → point the Cloudflare A records at the VPS → docker compose up the app with a Traefik router label. On a good day that's an evening. It was not always a good day.
What bit me, in order:
- Traefik couldn't see one of my containers. I was running a tool with
network_mode: host, and Traefik's Docker label discovery silently ignores host-network containers — no error, just no route. The fix was a static file-provider config (a smallfilebrowser.yml) that wires the route by hand. That static-provider trick is now my default for anything on the host network. - My certificates kept re-issuing. I hadn't persisted
acme.jsonto a volume, so every restart Traefik asked Let's Encrypt for fresh certs — and they rate-limit hard for a week. Mounting and backing up that one file ended it. - The orange cloud fought the challenge. With Cloudflare proxying in front, the HTTP cert challenge wouldn't complete cleanly. I set that record to DNS-only while issuing (or switch to a DNS challenge), then turned the proxy back on.
- My first test sale vanished. SQLite was sitting on the container's own disk, and a rebuild wiped it. Moving the DB file to a named volume fixed it for good.
- Stripe rejected every webhook. Signature errors, all of them — my JSON body-parser was rewriting the request before verification could see the raw body. Exempting that one route solved it instantly.
These are the ones that cost me real hours. Yours will be different — keep your own running list; it becomes the most useful page in the repo.
09The model, recapped
One sentence holds the whole thing: push all state into three external places — the database, the object store, and Stripe — and the app container is left holding nothing.
From that, everything follows. Maintenance is easy because a stateless app is disposable; you can restart, rebuild, or replace it without ceremony. Portability is easy because moving means relocating those three stores and redeploying a container that runs anywhere. Security is easy because the dangerous things — cards, master files — were deliberately kept off your box from the start. You didn't bolt safety on. You arranged the furniture so the unsafe things were never in the room.
10Exercises — turn reading into skill
- Draw your own trust boundary. Take Fig. 02 and, without looking, mark which steps the card touches and which steps the master file touches. If those two sets overlap anywhere, you've found a design bug.
- Break the redirect. On a test build, grant downloads on the
/thankspage instead of the webhook. Then load/thanksdirectly with no purchase. Watch it hand you the file. Now move the grant to the webhook and try again. - Force the race. Issue one token with
usesLeft: 1and fire five parallel download requests at it. Count how many succeed. Make the decrement atomic and repeat until exactly one wins. - Kill the container. Put SQLite on the container's local disk, take a sale, then
docker compose down && up --build. Confirm the order is gone. Move it to a volume and prove it survives. - Practice the move. Spin up a second cheap VPS. Using only your repo,
.env, and backups, bring the site up there. Time yourself. Anything that wasn't in those three is a portability leak — write it down. - Trace a refund. Sketch what should happen to an entitlement when Stripe sends a
charge.refundedevent. (Hint: the gate has a state this guide didn't draw.)
11Cheat sheet
The one rule
Payments
Downloads
Ops & portability
12Glossary
- Entitlement
- A database record granting one buyer access to one product, tracking downloads remaining and an expiry. The thing the download gate checks.
- Idempotent
- An operation that has the same effect whether you run it once or many times. Required for webhook fulfilment, because the same event can arrive twice.
- Webhook
- A server-to-server message a service sends your app when something happens — here, Stripe telling you a payment cleared. Signed, so it can't be forged.
- Signed token
- A string carrying claims (which entitlement it unlocks) plus a cryptographic signature, so tampering is detectable without storing the token server-side.
- Reverse proxy
- A front-door process (Traefik) that receives every request, handles TLS, and routes it to the right internal service.
- ACME / Let's Encrypt
- The protocol and authority Traefik uses to obtain and auto-renew free TLS certificates with no manual steps.
- Object storage
- File storage exposed over an S3-style API (R2, MinIO), kept separate from your server so files survive any rebuild.
- SAQ-A / SAQ-D
- PCI self-assessment tiers. SAQ-A is the short one you qualify for when card data never touches your systems; SAQ-D is the heavy one you owe if it does.
- Stateless
- A process that keeps no important data of its own, answering every request from external services — which makes it safe to kill and replace.
- Volume
- A storage area Docker keeps outside a container's throwaway filesystem, so data in it survives rebuilds.
13Sources
- Stripe — Fulfill orders (Checkout) · fulfil once per session, idempotent.
- Stripe — Receive Stripe events in your webhook endpoint · signature verification on the raw body.
- Stripe — PCI compliance guide · hosted Checkout and SAQ scope.
- PCI DSS — SAQ-A for hosted checkout pages.
- pdf-lib — official site & repository · pure-JS, runs in any JS runtime.
- Payload — SQLite adapter & database overview.
- Traefik — ACME / Let's Encrypt · automatic TLS & renewal.
- The Twelve-Factor App — config & processes · state out of the app, config in the environment.