We put a web server inside PostgreSQL (on purpose)
Nearly every web app runs the same three tiers. A browser talks to an app server. The app server talks to Postgres. That app server is written in Node or Python or Go, and if you read its code honestly, most of it is not business logic. It borrows a connection from a pool, sends a query, waits, gets rows back, turns those rows into objects, hands the objects to a template engine, and ships the rendered HTML back out. The data lives in the database. Most of the real work happens in the database. The app tier is a courier.
We kept staring at that courier. It carries a connection pool because talking to Postgres over TCP is expensive enough to amortize. It carries an ORM because nobody wants to hand-write the marshalling. It has its own process, its own deploy, its own way to fall over. For a large class of applications, its whole job is to move data between the database and a template. We wanted to know what happened if we deleted it.
The move
pg-web puts the HTTP listener inside Postgres. An Axum server runs in a Postgres background worker, started by a Rust extension built with pgrx. When a request arrives, the worker does not open a network connection to the database. It is already in the database. It calls your handler through SPI, the in-process interface Postgres uses to run SQL from C, and through pgrx, from Rust. The query hits the same shared buffers a normal backend would. No socket, no pool, no second process.
Your handler is a SQL function. It takes one argument, a json request carrying the body, query string, method, and path, and it returns json. If the route has a template, Tera renders that JSON into HTML in the same worker on the same request. You write .sql and .html files. The Rust is already compiled and shipped in the image; you never touch it.
A route is a folder. The path on disk is the URL.
-- pages/todos/[id]/index.sql
CREATE FUNCTION pgweb.pages__todos__id(req json)
RETURNS json LANGUAGE sql STABLE AS $$
SELECT to_json(t) FROM todos t
WHERE t.id = (req->'path_params'->>'id')::int;
$$;
And the template beside it renders the JSON that function returns:
<!-- pages/todos/[id]/index.html -->
<article class="todo">
<h1>{{ title }}</h1>
<p>{% if done %}done{% else %}pending{% endif %}</p>
<button hx-delete="/todos/{{ id }}">Delete</button>
</article>
What this is not
It is not stored procedures scattered through a database by hand. Your routes and templates are files in an ordinary project directory. The pg-web CLI reads that directory and upserts each file into a framework table: routes into pgweb.routes, templates into pgweb.templates, static files into pgweb.assets. Git still holds your source. The database holds the deployed copy. You edit files, run pg-web push, and the live worker picks up the new rows on its next lookup.
It is not a TLS terminator. The extension binds plain HTTP on port 8080. Caddy sits in front and handles certificates and HTTPS. We are deliberate about this. Certificate renewal has no business running inside a database background worker, and Postgres has no business parsing the open internet's handshakes.
It is not a claim that you can serve the whole world from one box. pg-web runs K HTTP workers behind SO_REUSEPORT (four by default) and lets the kernel balance connections across them, but each worker is single-threaded and each request holds a real Postgres backend. That model fits a lot of real software: internal tools, SaaS with thousands of users rather than tens of millions, sites where the database was always going to be the ceiling. It does not fit a service that has to shard across fifty machines. We are not pretending it does.
The invariant that keeps it honest
One rule holds the whole design together: one request is one SPI transaction. The worker opens a transaction when the request arrives. A clean 2xx response commits it. Anything else rolls it back, whether the cause is an exception in your SQL, a template that will not render, or a handler that runs past its timeout. There is no partial write to reconcile afterward, because there was never a moment when half your changes were visible and the other half were not. The request happened or it didn't.
This is the guarantee an app server usually has to rebuild by hand, wrapping handlers in transaction middleware and trusting every code path to honor it. Here it is not a convention you opt into. It is the shape of the request.
What fell out
Removing the app tier removed problems we had stopped noticing. There is no ORM, so there is no object-relational mismatch and no schema to keep in sync across two languages. SQL is the interface, on purpose. Deployment is one image: Postgres, the extension, and your app in a single container you start with docker compose up. There is nothing to wire between an app process and a database process because there is only one process tree.
The dev loop got shorter too. pg-web dev watches your files, pushes each change on save, and reloads the browser over server-sent events. The reload signal rides on Postgres LISTEN/NOTIFY, which was already there. No bundler, no build step, no node_modules.
Where it actually is
This is Phase 1: the synchronous core. It is shipped, benchmarked, and tested across five tiers on every change. It is also unfinished. There is no built-in auth yet; the plan is to lean on Postgres roles and row-level security rather than ship another half-built identity system. There is no background job queue yet; that is staged for later, built on SKIP LOCKED. We are keeping those out of the Phase 1 code paths on purpose, because the quickest way to wreck a small core is to smear unfinished features through it.
The longer version of how the pieces fit is on the architecture page.