The local loop
jfast dev
Containers up and waited for, migrations applied, then the API, the queue worker and the frontend together. One command for the things somebody does every morning, in the order that makes the failures land where they belong.
What it does, in order
| Stage | What runs | If it cannot |
|---|---|---|
| 1. Infrastructure | docker compose up -d <db> <cache>, then waits for health |
says why, and stops |
| 2. Migrations | alembic upgrade head |
stops |
| 3. API | uvicorn main:app --reload --no-proxy-headers |
— |
| 4. Worker | jfast worker, when the queue plugin is enabled |
says why, and carries on |
| 5. Frontend | npm run dev in the frontend project |
says why, and carries on |
The worker is there because the queue is on in every generated service, and a
queue nobody consumes is jobs piling up in jfast_jobs -- and event
subscribers that never run -- while the API answers 201. It boots the same
main:app, so it runs the same modules' @task and @subscribe. It does
not reload: restart jfast dev after changing a task or a subscriber.
--no-proxy-headersis not optional decoration. uvicorn ships its own forwarded-header resolver on, trusting127.0.0.1-- which is exactly whatservebinds -- and it rewrites the client address before any middleware runs, sotrusted_proxiesnever gets to decide.jfast devandjfast servepass it for you.
Every stage is skippable and every skip is announced:
jfast dev --no-infra # the containers are already up
jfast dev --no-migrate # you are mid-migration and know it
jfast dev --no-web # backend only
jfast dev --no-worker # no queue worker (run `jfast worker` yourself)
jfast dev --port 9000 # override the port in jfast.toml
No Docker on the machine, no compose file, no alembic.ini, no frontend: each
one degrades to a printed line and the rest still runs. A dev command that
refuses to start because the optional half is missing is a dev command people
stop using.
Why a failing migration stops everything
It is the one hard stop, and deliberately so.
A server booted against a schema that is behind does not fail at boot. It fails later, in a request that has nothing to do with the missing column, with an error that names a table rather than the migration nobody ran. The half hour that costs is worth more than the convenience of starting anyway.
✗ alembic upgrade head failed:
(psycopg.errors.UndefinedColumn) column "tenant_id" does not exist
Fix it, or pass --no-migrate and own the consequence.
The .env translation
This is the part worth understanding, because it is invisible when it works.
The generated .env is written for compose, where services address each
other by name and compose interpolates the password:
JFAST_DB_DSN=postgresql+asyncpg://app:${SHOP_DATABASE_PASSWORD}@shop-database:5432/app
Both halves of that are false for a process running on your machine.
shop-database resolves on the compose network and nowhere else, and nothing
interpolates ${SHOP_DATABASE_PASSWORD} for a plain uvicorn — the DSN would
reach asyncpg with the braces still in it.
So jfast dev rewrites both before it runs anything:
JFAST_DB_DSN=postgresql+asyncpg://app:s3cr3t@localhost:9431/app
The host port comes from what compose actually publishes, read out of the compose file. The same environment goes to Alembic and to the server, because Alembic runs first — getting the translation onto only the server means the migration fails with a DNS error naming a host that was never meant to resolve there.
Anything it cannot resolve is left exactly as it was. A wrong guess would be
harder to debug than the original value. A variable already set in your shell
is left alone too, as pydantic-settings leaves it over .env.
The translation is not only jfast dev's. jfast serve, jfast worker and
jfast migration check/plan apply it when they find the compose file the same way (the service directory or
one level up), and jfast exec -- <command> runs anything else with it --
jfast exec -- alembic revision --autogenerate -m "add invoices", jfast exec
-- pytest. Inside a container nothing is translated: there the compose names
resolve and localhost is the container. The production image has no compose
file to find in any case (.dockerignore drops it, and the workspace's lives
outside the build context).
Stopping it
Ctrl-C stops everything it started. So does SIGTERM — from timeout, from a
supervisor, from closing the terminal.
That second one needed its own handler and is worth knowing about if you ever
edit this. The children are deliberately put in their own process groups,
so a Ctrl-C at the terminal does not reach them directly and the parent can
shut them down in order. But the default disposition for SIGTERM kills the
interpreter outright — no finally, no cleanup — and the children, being in
their own groups, would have survived as orphans still holding the ports. The
next jfast dev then fails with address already in use and a confusing hunt.
scripts/smoke_dev.sh asserts it: start, SIGTERM, count what is left.
dev versus serve
jfast serve |
jfast dev |
|
|---|---|---|
| Backend | yes | yes |
| Containers | no | brings them up |
| Migrations | no | applies them |
| Queue worker | no (jfast worker) |
starts it |
| Frontend | no | starts it |
.env translated for the host |
yes | yes |
serve is the smaller tool and stays that way: one process, no side effects,
nothing started that you have to remember to stop. Reach for it when the
database is already running and you want a server and nothing else.
Everything in containers instead
(cd shop-web && npm install && npm run build)
docker compose up --build
Which is what runs in production. The frontend is the one thing built on the
host: Caddy serves <frontend>/dist, where npm run build writes, and no Node
container runs. The compose file jfast start writes is complete — the workspace .env with the
generated passwords and each service's .env are written alongside it, so this
works on a fresh clone with no further steps.
Use jfast dev when you want a reload loop and a debugger. Use compose when
you want to know it works the way it will be deployed.