Engineering notes

ENGINEERING NOTE 02

Edge to Database: Next.js and PostgreSQL Behind Cloudflare

The public site is a Cloudflare Worker in front of an Access-protected tunnel to one small instance running Next.js and PostgreSQL, and every request's trust is verified in code rather than assumed from the network.

  • Next.js standaloneRuntime — A Node 22 container serves pages and route handlers from the framework's standalone output.
  • PostgreSQL 16Persistence — Curriculum releases, accounts, sessions, progress, responses, bookmarks, entitlements, and audit events live in one durable database.
  • 0Inbound ports — The instance's security group has no ingress rules; public traffic arrives through an outbound-only Cloudflare Tunnel.

SECTION 01

Keep the topology deliberately small

A browser reaches a Cloudflare Worker, the Worker reaches the origin through Cloudflare Access and an outbound-only tunnel, and only Next.js talks to PostgreSQL over the Compose network.

The production image compiles a standalone Next.js application and runs it as an unprivileged Node process on a single small EC2 instance. Docker Compose runs four services there: the web server, PostgreSQL 16, cloudflared, and Grafana Alloy. Compose waits for PostgreSQL health before starting the web service, injects the database connection only into the server container, and persists the database in a named volume. Web and database ports are bound to the loopback interface for health checks and operator sessions, and the security group has no ingress rules at all. The browser never connects to PostgreSQL. Server components and route handlers query through the postgres client, while client components call same-origin JSON endpoints for mutations and interactive state.

Public requests take one fixed path. The site hostname is served by a Cloudflare Worker that proxies to an origin hostname protected by Cloudflare Access, whose only policy admits the Worker's service token. cloudflared on the instance holds an outbound connection to Cloudflare and requires a valid Access token again before forwarding to the web container. That separation matters more than the number of containers: curriculum pages render from the published release, assessment handlers create and grade sessions transactionally, browser-only affordances such as narration stay on the client, and one database remains the state authority, so a learner who signs in on a second device sees the same progress without reconciling browser caches.

Production request pathThe browser talks only to the edge Worker; the origin is reachable only through Access and the tunnel; database traffic stays inside the Compose network.A browser communicates only with the Cloudflare Worker. The Worker authenticates to Cloudflare Access with a service token, and the request reaches Next.js through an outbound-only tunnel. Next.js performs server-side database work against PostgreSQL, whose data is retained in a Docker named volume. Connections: Browser to Edge Worker (HTTPS); Edge Worker to Access + tunnel (service token); Access + tunnel to Next.js web (outbound-only tunnel); Next.js web to PostgreSQL (parameterized SQL); PostgreSQL to Named volume (persistent storage).
  1. Browser

    Receives HTML over HTTPS and runs explicit client components.

  2. Edge Worker

    Rate limits auth and telemetry posts, hides admin paths, sets trusted client headers.

  3. Access + tunnel

    Admits only the Worker's service token; cloudflared connects outbound from the host.

  4. Next.js web

    Standalone Node server; proxy.ts verifies the Access token on every route.

  5. PostgreSQL

    Published curriculum, accounts, and per-learner state.

  6. Named volume

    Durable database files across container replacement.

SECTION 02

Place each capability on the correct side of the boundary

Database credentials, grading keys, and release operations stay in server-only modules; pointer interactions, local preferences, and speech synthesis stay in client components.

The database and session-store modules import server-only, making an accidental client import fail during the build rather than leaking a connection path into a browser bundle. Route handlers validate JSON payloads, check same-origin mutation requests, and translate expected failures into public errors. Interactive components receive already-reduced data and never need the database connection string. This gives the codebase a reviewable boundary even though server and client code share one TypeScript project.

The browser still owns transient presentation state: which lesson section is active, whether narration is paused, which locally installed voice is selected, and which pathway card is expanded. Durable learning state—progress, answers, bookmarks, release IDs, and audit records—returns to PostgreSQL. Keeping those categories distinct prevents a cleared browser profile from erasing learning history and prevents convenience preferences from becoming account policy.

State placement — Durability and sensitivity determine where state belongs.
StateOwnerReason
Published release and lesson progressPostgreSQLDurable, scoped to each learner, and transactionally updated.
Session item snapshots and grading keysServer + PostgreSQLMust not be trusted to the browser before feedback is due.
Narration voice and speedBrowser local storageDevice capability and personal preference vary by browser.
Pathway preview selectionBrowser local storageA reversible planning preference, not completion evidence.

SECTION 03

Scope every learner query to an account

Each account owns one learner row, so the database coordinates progress across that person's devices while keeping one learner's evidence apart from another's.

Accounts sign in with an email address or, for operator-provisioned accounts, a username. Passwords are stored as scrypt hashes. A sign-in creates a random opaque session token; the browser keeps it in an HTTP-only cookie, and the database stores only its SHA-256 digest. Every progress and assessment query takes its learner ID from that session, and session reads match both the session ID and the owning learner, so another attempt's identifier returns nothing. Monotonic lesson updates use a database greatest operation so an older 25% request cannot overwrite a concurrently saved 95% result.

The model grew out of a single shared learner. Before accounts existed, every query used one fixed learner row; the first account registered afterwards claimed that row and its history, and every later account receives a fresh one. Registration, sign-in, and password-reset forms carry a Turnstile challenge and are rate limited both at the edge and in the app. What an account may open is a separate question answered by entitlements: a free account gets a short diagnostic practice set and a lesson preview, while full access is an entitlement row checked on the server, never a browser flag.

SECTION 04

Verify every request's door, not its network

Noindex and private hostnames are context, not controls; the app verifies a signed Cloudflare Access token before it believes forwarded client headers or serves an admin route.

The Worker strips client-supplied forwarding and X-Portal headers and writes trusted ones: client IP, country, region, city, and Cloudflare ray ID. The app does not take that on faith. proxy.ts runs before every non-static route, verifies the Cf-Access-Jwt-Assertion header as an RS256 token against the Access team's published signing keys, and reads its audience to decide which door the request used. Only the public origin application's audience makes the Worker's headers trustworthy, and only the admin application's audience makes admin routes reachable. Requests without a valid token are refused, apart from the container health check and a bearer-protected metrics scrape.

Admin pages live on a separate hostname behind their own Access application, which sends an email one-time PIN to allowlisted addresses; past that, the app still requires sign-in by an account on its admin allowlist. The Worker returns 404 for admin and internal paths, and proxy.ts does the same for any public-door request that reaches them. Robots policy is a separate concern: the root layout applies noindex and nofollow by default, only the blog, legal pages, and pricing opt in to indexing, and robots.txt disallows learner and admin prefixes. That keeps graded results out of search, but it is not authorization. Response headers add a restrictive content security policy, deny framing, limit device permissions, and set HSTS.

Where each control lives — Each layer narrows a different risk; none of them substitutes for the session check every learner and admin handler performs.
LayerResponsibilityEvidence
Edge WorkerRate limits, 404 for admin and internal paths, trusted client headersWorker unit tests with mocked fetch
Access + tunnelAdmit only the Worker's service token or an allowlisted adminAccess applications and policies in OpenTofu
proxy.tsVerify the Access JWT and its audience before trusting headersEdge-trust unit tests
HostNo ingress rules, no SSH key, loopback-only published portsSecurity group and Compose port bindings
Account sessionLearner routes need a session; admin routes need an allowlisted accountServer-side guards on each handler and page

SECTION 05

Deploy, observe, and back up without opening a port

A Forgejo pipeline provisions and deploys through APIs and AWS Systems Manager, telemetry leaves through its own tunnel, and nightly dumps go to S3, so nothing needs an inbound connection.

A Forgejo pipeline deploys the stack. OpenTofu provisions the instance, container registry, backup and operations buckets, parameter-store secrets, tunnels, Access applications, and Turnstile widget. The image is built once with the public site URL baked in and pushed to ECR, and wrangler deploys the Worker. An Ansible bundle then runs on the host through AWS Systems Manager, because there is no SSH key or inbound port to use. Ansible renders the environment file from the parameter store, pulls the image, migrates, starts the stack, and waits for the health endpoint to report the new version. If it does not, the previous version is restarted and the deployment fails; migrations are not reversed.

Grafana Alloy on the instance tails container logs, scrapes host, database, tunnel, and application metrics, and forwards application traces. It ships them through a second tunnel, guarded by its own Access service token, to a self-hosted Prometheus, Loki, Tempo, and Grafana stack. A systemd timer takes a nightly pg_dump, uploads it to an encrypted, versioned S3 bucket that expires backups after 30 days, and writes a freshness metric that an alert watches. An uploaded archive is not a restore drill; restore remains a deliberate, separately confirmed operation.

REFERENCES

Sources and further reading

  1. Server and Client ComponentsNext.js

    Framework reference for keeping data access on the server while isolating interactive browser code behind explicit client boundaries.

  2. Deploying a Next.js applicationNext.js

    Framework deployment reference for the standalone Node.js output used by the container image.

  3. TransactionsPostgreSQL Global Development Group

    Database reference for all-or-nothing release changes, session creation, grading, and auditable state transitions.

  4. Validate JWTsCloudflare Zero Trust documentation

    Reference for verifying the Cf-Access-Jwt-Assertion header against the team's published signing keys, which proxy.ts does before trusting forwarded client headers.