Taking a Sillo project to production: settings, migrations, workers, static files, a reverse proxy, and the checks worth running before you ship.
Deployment
Section titled “Deployment”uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4Under a process manager, behind a reverse proxy. Everything else on this page is what to change before that is a good idea.
Settings
Section titled “Settings”APP_ENV=productionDEBUG=falseSECRET_KEY=<a real secret>DATABASE_URL=postgres://user:password@host:5432/myappCORS_ALLOW_ORIGINS=https://myapp.example.comLOG_LEVEL=infoDEBUG=false matters. With it on, error responses carry tracebacks (your
file paths, your local variables) to whoever provoked them.
SECRET_KEY signs sessions. A shared or published one lets anyone
forge a session cookie. Generate one per environment:
python -c "import secrets; print(secrets.token_urlsafe(48))"sillo-start does this when it creates the project. It does not happen when you
copy .env between machines, which is the moment to check.
CORS_ALLOW_ORIGINS should name your front end, not *. The default
is a local Vite server, which is wrong everywhere else.
Migrations
Section titled “Migrations”Run them as a separate step before the new version starts, never from application startup code:
uv run sillo db:migrate && exec uvicorn app.main:app --host 0.0.0.0 --port 8000Three replicas that each migrate on boot produce three concurrent schema changes and, on a good day, two failures.
For rolling deployments, make it a job that runs once and gates the rollout, and keep each migration compatible with both the old and the new application version:
- add a column before the code that writes to it
- drop a column a release after the code that read it is gone
- add an index concurrently, in its own migration
uv run sillo db:plan # what would run, before it runsis worth putting in front of a production migration.
Workers
Section titled “Workers”If you use the queue, run it properly:
QUEUE_URL=redis://redis:6379 uv run sillo queue:work --concurrency 8The in-memory queue does not survive a restart and is not shared between processes, so with more than one application replica it is not a queue. It is four separate queues that each lose their contents on deploy.
# app/bootstrap.py — drop in_process for a real deployment_register_work(application)run_worker installs a SIGTERM handler, so a container stop finishes the
job in flight rather than killing it halfway.
Static files
Section titled “Static files”Serve them with a web server, not with Python:
location /static/ { alias /srv/myapp/static/; expires 30d;}
location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;}The /static mount in bootstrap.py is for development and small
deployments. With a proxy in front it never sees traffic.
The forwarded headers matter: without X-Forwarded-Proto, the application
believes it is on HTTP and will mark secure cookies wrongly.
Workers and SQLite do not mix
Section titled “Workers and SQLite do not mix”uvicorn app.main:app --workers 4 # with SQLite: contention and lockingSeveral processes writing one SQLite file contend for locks and will
produce database is locked under any real load.
Use PostgreSQL or MySQL in production, or stay on a single worker. SQLite is an excellent default for development and a poor one for concurrency.
A container
Section titled “A container”FROM python:3.12-slim
RUN pip install --no-cache-dir uv
WORKDIR /srv/myappCOPY pyproject.toml uv.lock ./RUN uv sync --frozen --no-dev
COPY . .
ENV APP_ENV=production DEBUG=falseEXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]--frozen installs exactly what uv.lock records, so the image matches
what you tested. --no-dev leaves pytest and ruff out.
Migrations belong in the deployment, not in CMD, otherwise every replica
migrates:
# one job, before the rolloutcommand: ["uv", "run", "sillo", "db:migrate"]A systemd unit
Section titled “A systemd unit”[Unit]Description=MyappAfter=network.target
[Service]Type=execUser=myappWorkingDirectory=/srv/myappEnvironmentFile=/etc/myapp.envExecStart=/usr/local/bin/uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4Restart=alwaysRestartSec=5
[Install]WantedBy=multi-user.targetAnd the worker, if you have one:
[Service]ExecStart=/usr/local/bin/uv run sillo queue:work --concurrency 8Restart=alwaysKillSignal=SIGTERMTimeoutStopSec=60TimeoutStopSec should exceed your longest job, so a restart lets the job
in flight finish rather than killing it.
The admin in production
Section titled “The admin in production”It is on by default at /admin/. Decide deliberately:
ADMIN_ENABLED=false # not in this deploymentADMIN_PREFIX=/staff-only # or somewhere less obviousAccess is is_staff, checked on every request, so revoking it takes effect
immediately rather than at that person’s next sign-in.
The query console at /admin/query/ grants read and write on every table
and is superuser-only. If that is more power than you want to exist in
production, disable the admin there and use it against a replica.
Before you ship
Section titled “Before you ship”A checklist that is short because each item has cost someone a bad afternoon:
-
DEBUG=false -
SECRET_KEYunique to this environment, not copied from.env.example -
DATABASE_URLpointing at PostgreSQL or MySQL, not SQLite, if you run more than one worker -
CORS_ALLOW_ORIGINSnaming real origins, not* - Migrations run as a separate step, gating the rollout
-
sillo db:planreviewed for anything destructive - Static files served by the proxy
-
X-Forwarded-Protoset by the proxy - Worker running with
QUEUE_URL, if you dispatch jobs - Exactly one scheduler, if you have scheduled tasks
- An administrator account created
- Lint, tests and the smoke check green on the commit being deployed
Health checks
Section titled “Health checks”/api/health is provided and cheap:
livenessProbe: httpGet: { path: /api/health, port: 8000 } initialDelaySeconds: 10 periodSeconds: 30For a readiness probe that means “can serve traffic”, add one that touches the
database. The manager exposes health():
from sillo import HttpContext, json
@router.get("/ready")async def ready(ctx: HttpContext): manager = ctx.app.state["record"] return json({"ready": await manager.health()}, status_code=200)A liveness probe that queries the database will restart your application whenever the database hiccups, which is rarely what you want. Keep them distinct.
Logging
Section titled “Logging”LOG_LEVEL=infoDB_ECHO=true logs every query. Useful locally; in production it is a
performance problem and a way to write credentials into logs.
The application logs its own lifecycle at startup: “Database connected”, and the reverse at shutdown. The console quiets those, because they are noise around a one-shot command and signal in a long-running process.
Upgrading sillo
Section titled “Upgrading sillo”The starter pins a floor:
"sillo-framework[cache,hashing-bcrypt,jwt,record,mail]>=0.0.1a8",To move:
uv lock --upgrade-package sillo-frameworkruff check . && pytest && python scripts/smoke.pyThat last line is the point of the sequence. It lints, tests, and boots the application against the new version, which is what catches a release that changes something your project depends on.
The starter’s own CI runs weekly for the same reason.
Things that will bite you
Section titled “Things that will bite you”-
--workers 4with SQLite produces lock contention under load. -
Migrating from application startup races across replicas.
-
Copying
.envbetween environments copies the signing key with it. -
Forgetting
X-Forwarded-Protomakes the application think it is on HTTP. -
uvicorn app:app --reloadis not a production server. It is one process with reload watching your files. -
One scheduler per replica means your nightly job runs four times.
Related
Section titled “Related”- Creating a Project: what you are deploying
- Database & Migrations: migrating safely
- Background Work: workers in production
- Testing: the suite and the smoke check
- The Console: the commands a deployment runs