From rails to a paved road
June 2026 • 1214 words • 7 min read
Pave is a full-stack Python template built on FastAPI: auth, background jobs on Postgres, an HTMX component kit, and a one-command deploy to a real Linux box. No Docker, no Kubernetes, no Node build step. It exists because Rails and Phoenix made building and shipping feel easy years ago, and Python never quite did. Boring on purpose.
For years, the most fun I had building anything was on Rails and Phoenix.
Not because of the languages. Because of the feeling. You ran one generator, you got a working app with auth, a database, background jobs, and a deploy story, and then you spent your time on the thing you actually wanted to build. The framework had already made a hundred decisions for you, and most of them were right.
Then I would go back to Python and feel the difference immediately.
Python is the language I reach for most. But for years the honest answer to “how do I structure a real web app in Python” was Django, and Django is excellent at exactly the kind of app it was designed for. FastAPI is where a lot of new energy went, and deservedly so. It is fast, it is typed, the developer experience is lovely. But FastAPI hands you a request router and wishes you luck. Auth, sessions, jobs, deploy, the folder layout, the thing that turns a tutorial into a product - all of that is left as an exercise.
So everyone writes that glue themselves. Badly, usually, and differently every time. I have done it more than once, and did not enjoy it any of the times.
Pave is my attempt to stop writing it.
The pitch is one line: full-stack Python, no React required. You write FastAPI. Pave handles the parts around it - auth, background jobs, an HTMX component kit, a markdown content system, and a deploy pipeline - and it ships your app to a real Linux box with one SSH command.
boring on purpose
The first decision I made was the one most people will argue with, so let me say it plainly. No Docker. No Kubernetes. No Node build step.
I want to be careful here, because this is a preference, not a law. There are good reasons to reach for containers, and if you already run a fleet on Kubernetes, Pave is not asking you to tear it down. But for the app that does not exist yet - the one you are trying to get to its first real user - all of that is ceremony you pay for before you have earned it.
A handful of long-lived Python processes supervised by systemd will carry most apps a lot further than people admit. So that is what Pave does. It ships Python files to an Ubuntu host and runs them under systemd, behind Nginx, served by Gunicorn. Migrations with Alembic. The ORM is SQLAlchemy 2.0, async. Deploy is Fabric.
The whole thing is the kind of stack a sysadmin in 2010 would recognize, and I mean that as the highest compliment I can give infrastructure. It is boring. Boring things do not page you at 2am.
If you have heard “you’ll regret not using Docker” enough times to half-believe it, here is the honest trade-off. The day you genuinely outgrow one box, you will have to do some work to move. But there is no Pave-specific lock-in to undo first, and you will be doing that work with real traffic and real numbers in front of you, instead of guessing about scale you do not have yet.
the job queue problem
Background jobs are where the boring-infra promise usually breaks.
You want to send a verification email without making the user wait for it. Easy enough. So you reach for a job queue. The standard answer in Python is Celery, and Celery wants Redis or RabbitMQ. Now your “one box, one database” app has grown a second piece of infrastructure whose only job is to hold a list of things to do later.
You already have a database that is very good at holding lists of things. It is right there. It is Postgres.
So Pave’s job queue, Soniq, runs on Postgres. No Redis, no broker, no new daemon to babysit. It does the unglamorous things a queue needs to do - retries, a worker you run with one command - and it does them against the database you were already running. Two real jobs ship with Pave as worked examples, not toys: processing a webhook, and sending that verification email off the request path.
I wrote Soniq as its own library because I kept wanting it in projects that had nothing to do with Pave. But this is the project where it earns its keep. One fewer moving part is not a small thing. It is the whole point.
no React, and I mean it
The other place I dug in is the frontend.
I have watched the React tax get paid for more than a decade, on teams I led that shipped React and React Native. My own frontend JavaScript was Backbone, back when that was the reasonable choice. So for this class of app I am opting out, and the reflex I am opting out of is reaching for a separate frontend build, a Node toolchain, and a single-page app for what is fundamentally a few forms and an admin dashboard. You are now running two applications that have to agree with each other, and a build step that breaks in its own special ways.
Pave renders on the server. The components are Jinja2 templates, made interactive with HTMX and a little Alpine.js, styled with Tailwind. Every component traces back to one set of design tokens, so the buttons match the forms match the cards without anyone freelancing a hex code at midnight. Dark by default, with a real light theme, responsive down to a phone, all from the same templates. No separate mobile build.
There is no package.json. Tailwind runs from a standalone binary. I will admit I was a little smug the first time I deleted node_modules from a project and nothing broke. t This will not suit everyone, and the README says so. If you are building a rich SPA, the HTMX kit will fight you, and you should use something built for that fight. But for the enormous category of apps that are mostly server-rendered pages with some interactivity, all of that JavaScript machinery was solving a problem you did not have.
deploy in one line
Here is the part I am most quietly proud of.
Once a server is prepared, you deploy with fab production deploy. That one command validates your environment schema, builds the CSS, runs migrations and tests and the type check, snapshots a new release on the server, flips a symlink to make it live atomically, restarts the systemd service, and then probes /health. If the health check fails, it rolls back on its own before the command even returns to your prompt.
That last sentence is the one that matters. The deploys that hurt are not the ones that fail loudly. They are the ones that half-succeed and leave you SSHed into a box at midnight trying to remember what the last good state was. The release-directory model and the automatic rollback exist so that a bad deploy is a non-event. The old version never went anywhere. You flip back.
That is the part Python never gave me. Not the speed of the first deploy, the calm of every one after it. Rails and Phoenix made deploying boring years ago, and boring is exactly what you want from the thing that puts your work in front of people. This is me trying to make it boring in Python.
what this actually is
Pave is not a framework. It is a template - a starting point you clone, with the boring decisions already made and wired together correctly, so you can delete the ones you disagree with instead of assembling everything from nothing.
It is pre-1.0, so the API may still shift. The name comes from the obvious place: you pave a path before you walk it. Pave lays the production surface down first, then you build on top.
I built it because I wanted, in Python, the absence of a hundred small fights before the first real line of my own code. Not the magic. Just that.
It is MIT licensed and on GitHub at https://github.com/abhinavs/pave. Fork it, ship it, change the name on the tin.
I am going back to building the app now. Which, I suppose, was the entire idea.