Home / Infrastructure / The Stateless Storefront ← Back to home
Author · Ajai Raj iolinked.com

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.

Fig. 00 — Hero · the mental model The app container holds no state; all state lives in three external services. HOSTINGER VPS — REPLACEABLE BUYER TRAEFIK · TLS · EDGE APP CONTAINER Next.js + Payload STATE LIVES NOWHERE stateless · disposable · rebuildable STATE LIVES HERE Database orders · entitlements uses-left · expiry Object Storage master PDFs (private) site media Stripe payments · the money truth card data never touches you state →
The one idea everything else serves: the box that runs your code carries nothing worth keeping. Orders, files, and money live in three services outside it. Kill the box, spin up another, point it at the same three — and you're back, unchanged.

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."
Note · the organizing principle

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

Unlock 01

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.

Unlock 02

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.

Unlock 03

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.

Fig. 01 — Layered stack · edge to backing services The stack from the host at the bottom to backing services attached on the right. RUNS ON YOUR VPS Host · Ubuntu + Docker the bare machine — interchangeable Traefik · reverse proxy + TLS doorman: HTTPS, IP allowlist, rate limit Next.js + Payload · one app public site · admin /admin · API routes checkout route webhook route download route CONFIG IN .env · SCHEDULED BACKUPS (cross-cutting) BACKING SERVICES (attached) Databaseorders · entitlements Object storage (S3-compatible)master PDFs · media Stripehosted checkout · webhooks Email providerreceipts · download links
Everything on the left runs inside your server and is disposable. Everything on the right is attached over the network and holds the state. The dashed purple band — config and backups — touches everything and is the difference between "portable" and "portable but doomed."

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.

Warning · the silent data-loss trap

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

What it is

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.

How it works

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.

What breaks it

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

What it is

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.

How it works

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.

What breaks it

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.

Fig. 02 — Process flow · purchase → watermark → stream Eleven-step flow from browsing the store to streaming a watermarked PDF. 01 Buyer browses the store 02 Clicks buy → checkout route makes a Stripe session 03 Redirect to Stripe's hosted page card is entered HERE — never on your server 04 Stripe → webhook route: payment ok signature verified · this is the source of truth 05 Create entitlement in DB · uses=3 · expires 24h 06 Issue a signed, expiring token to the buyer 07 Buyer requests download, presenting the token TRUST BOUNDARY — MASTER FILE NEVER LEAVES THIS BAND 08 Validate token · decrement uses (atomic) 09 Pull master PDF from private storage 10 pdf-lib stamps name/email onto a COPY 11 Stream the watermarked PDF to the buyer BUYER STRIPE STORAGE
Read top to bottom. Steps 03 (card on Stripe's page) and 04 (the signed webhook) are why a breach of your server never exposes a card. The red band around 08–11 is the rule that keeps a paid file from becoming a free one: the master is read, stamped, and streamed — but never handed out as itself.
Fig. 03 — State machine · the entitlement's life Entitlement state machine from paid through exhausted or denied. PAID ENTITLEMENT LIVE uses=3 · exp=T+24h VALIDATE token · uses · expiry SERVE COPY uses − 1 DENY · 403 expired / used up / bad sig EXHAUSTED ok fail next download uses→0
Each download walks the same loop: validate, and on success serve a copy and burn one use. The entitlement only moves forward — toward denied or exhausted, never back. That one-way ratchet is what a "3 downloads in 24 hours" promise actually is.

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.

  1. 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@…" }.
  2. Her browser is redirected to checkout.stripe.com/c/pay/cs_live_…. Your server has not seen a card number, and never will.
  3. She pays. Stripe shows its own success page, then bounces her back to store.ajaitookit.cc/thanks. You do not trust this page.
  4. Separately, Stripe POSTs checkout.session.completed to /api/webhooks/stripe. Your handler reads the raw body, verifies the Stripe-Signature against your webhook secret. Valid.
  5. The handler checks it hasn't already processed cs_live_… (idempotency), then writes an entitlement: { email, product, usesLeft: 3, expiresAt: now+24h }.
  6. It mints a signed token eyJ…stamp bound to that entitlement, and emails Priya a link: …/download?t=eyJ….
  7. Priya opens the link. The download route verifies the token's signature — untampered — and loads the entitlement: alive, usesLeft: 3.
  8. 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.)
  9. It fetches the master can-bus-field-guide.pdf from the private bucket — a path no public route maps to.
  10. pdf-lib draws, on a copy, a footer on every page: “Licensed to Priya Sharma · priya@… · do not distribute.”
  11. 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

Fig. 04 — Comparison · where the card flows Hosted checkout keeps card data off your server; a custom form puts it in scope. Hosted Checkout — this design buyer your server Stripe card → (skips your server) SAQ-A · ~26 questions card never stored / transmitted by you breach of your box ≠ card leak Custom card form buyer your serversees the card Stripe card passes THROUGH you SAQ-D · 300+ controls scans, audits, full liability a breach is a card breach
The single most consequential design choice on the whole site, drawn. Let Stripe host the card field and your compliance burden is a short form. Build your own card field and you inherit the full weight of PCI — for no real upside.
DecisionPick the first when…Pick the second when…
SQLite · vs · PostgresLow 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 · MinIOYou 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 formAlmost 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 · edgeThe 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.
Opinion

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.

FailureWhat actually happensThe rule that prevents it
Trusting the redirectSomeone 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 webhooksStripe re-sends an event; you fulfil twice; one purchase yields six downloads.Record processed session IDs; fulfil once per session, idempotently.
Mangled raw bodyA JSON body-parser rewrites the request before verification; every signature check fails in production.Feed signature verification the untouched raw body.
Race on uses-leftThree parallel downloads each read “1 left” and all succeed.Validate and decrement in one atomic DB operation, before streaming.
Public master URLThe 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 tokenA 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 containerSQLite 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.jsonCert 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 gitA pushed .env leaks live Stripe keys to anyone who clones..env in .gitignore; secrets only in the environment.
Webhook timeoutHeavy 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.

Fig. 05 — Portability · the host is replaceable, the state is relocated Moving servers means redeploying a disposable container and reattaching three state stores. OLD VPS (Hostinger) app container throwaway NEW VPS (anywhere) app container same image THE THINGS THAT ACTUALLY MATTER DB dump / volume object storage (already off-box) Stripe (external) THE WHOLE MIGRATION 1 · git clone 2 · drop in .env 3 · restore volumes 4 · docker compose up
The container on the left is disposable — you don't move it, you abandon it and build a fresh one from the same image on the right. All you carry over is the three stores in the middle, two of which (storage, Stripe) were never on the box to begin with. Four commands, and the brand on the bill is the only thing that changed.
Warning · the one Hostinger-specific caveat

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:

  1. 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 small filebrowser.yml) that wires the route by hand. That static-provider trick is now my default for anything on the host network.
  2. My certificates kept re-issuing. I hadn't persisted acme.json to 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.
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. Break the redirect. On a test build, grant downloads on the /thanks page instead of the webhook. Then load /thanks directly with no purchase. Watch it hand you the file. Now move the grant to the webhook and try again.
  3. Force the race. Issue one token with usesLeft: 1 and fire five parallel download requests at it. Count how many succeed. Make the decrement atomic and repeat until exactly one wins.
  4. 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.
  5. 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.
  6. Trace a refund. Sketch what should happen to an entitlement when Stripe sends a charge.refunded event. (Hint: the gate has a state this guide didn't draw.)

11Cheat sheet

The one rule

push state outDB + object store + Stripe hold everything; the app holds nothing

Payments

grant onverified webhook — never the browser redirect
verify withraw body + Stripe-Signature + webhook secret
fulfilonce per session (idempotent); ack in < 20s
PCIhosted checkout = SAQ-A; card never touches you

Downloads

tokensigned · expiring · use-limited · bound to entitlement
order of opsvalidate → decrement (atomic) → fetch master → stamp copy → stream
master fileprivate bucket, no public route, never served as itself
watermarkpdf-lib, on a per-request copy, at download time

Ops & portability

secrets.env only, gitignored
persistDB volume + acme.json + uploads (or external store)
migrationclone → .env → restore volumes → docker compose up
backupsscheduled DB dump + object-store snapshot

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


Author · Ajai Raj iolinked.com