Branch previews with Caddy Snake and database clones
Caddy with our plugin added makes it easy to run preview environments for your Python apps. The setup below serves production and every branch preview from one virtual machine and one Postgres database: Caddy Snake loads each branch as a dynamic app, and branchable clones a schema per preview.
The shape of the system
Hardware and data:
- 1 VM: a single Caddy Snake process terminates TLS, serves static files, and runs the Python app for production and all previews
- 1 Postgres instance: production uses the primary schema; each preview gets its own cloned schema on the same server
DNS (all pointing at the VM's public IP):
| Record | Type | Value |
|---|---|---|
app.example.com | A | VM IP |
*.preview.example.com | A | VM IP |
Slug from branch name
The {slug} in the hostname and release path comes from the Git branch name. A small sanitizer turns it into a DNS label:
- Lowercase the branch name
- Replace
/,_, and whitespace with- - Strip other characters that are not
a-z,0-9, or- - Collapse repeated hyphens and trim edges
- If the result starts with a digit, prefix
p-(DNS labels cannot start with a digit) - Truncate to 63 characters (DNS label limit)
Examples:
| Branch | Slug | Preview URL |
|---|---|---|
feature/login | feature-login | https://feature-login.preview.example.com |
fix/api_v2 | fix-api-v2 | https://fix-api-v2.preview.example.com |
123-experiment | p-123-experiment | https://p-123-experiment.preview.example.com |
The database name uses the same slug with hyphens turned into underscores, for example preview_feature_login.
Caddyfile config
Example configuration for ASGI app (FastAPI or others):
{
# Enables automatic HTTPS for preview hostnames without listing each one.
# python_dir allows a certificate only when /srv/releases/{slug} exists
# and the host matches *.{domain_suffix}.
on_demand_tls {
permission python_dir {
root /srv/releases
domain_suffix preview.example.com
}
}
}
# Production app
app.example.com {
handle_path /static/* {
root * /srv/releases/active/staticfiles
file_server
}
python {
module_asgi "main:app"
working_dir "/srv/releases/active"
venv "/srv/releases/active/.venv"
env_file "/srv/releases/active/.env"
lifespan on
}
}
# Preview apps (one hostname / slug per Git branch)
https://*.preview.example.com {
tls {
on_demand
}
handle_path /static/* {
root * /srv/releases/{http.request.host.labels.2}/staticfiles
file_server
}
python {
module_asgi "main:app"
working_dir "/srv/releases/{http.request.host.labels.2}/"
venv "/srv/releases/{http.request.host.labels.2}/.venv"
env_file "/srv/releases/{http.request.host.labels.2}/.database.env"
env_var ALLOWED_HOSTS "{http.request.host.labels.2}.preview.example.com"
env_var CSRF_TRUSTED_ORIGINS "https://{http.request.host.labels.2}.preview.example.com"
lifespan on
autoreload
}
}
Host labels are numbered from the right, so feature-login.preview.example.com resolves labels.2 to feature-login.
How the pieces fit together:
- Dynamic apps: the first request for a slug starts workers for that directory; later requests reuse them until idle eviction (default cap 128 apps, ~30m idle TTL).
- On-demand TLS:
tls.permission.python_dirissues a certificate only if/srv/releases/{slug}exists. Unknown slugs do not get certificates. - Per-preview env:
.database.envsetsDATABASE_NAMEfor that clone;ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINScome fromenv_varwith the same hostname placeholders.
Database branching with branchable
On preview deploy, clone the primary schema:
branchable branches create --base-schema app --branch-name "preview_${SLUG}"
On Postgres 18+, branchable can use a copy-on-write template clone when available; otherwise it falls back to template/pg_dump.
CI: deploy and cleanup
On push to a non-main branch (open a preview):
- Derive
{slug}from the branch name (see above) - Pack the branch into a tarball
- Upload it to the VM (SCP/SSH)
- Extract to
/srv/releases/{slug}/and install dependencies - Create the database branch with branchable (if it does not already exist)
- Run migrations (and collectstatic if needed)
- Touch a small
.pyfile or rely onautoreloadso the preview picks up the new code - Expose
https://{slug}.preview.example.comas the environment URL
No Caddy restart is required for preview deploys.
On merge (or when the branch is deleted), tear the preview down:
- Delete the release directory:
rm -rf /srv/releases/{slug} - Delete the database branch:
branchable branches delete preview_{slug}
Security
The setup above shares one VM and one Postgres instance between production and previews. That is fine when every branch is trusted (same team, same secrets). For stronger isolation:
- Run previews on a second VM with its own Caddy Snake process and the wildcard
*.preview.example.comsite only - Keep production on the first VM (
app.example.com) - Use a separate Postgres for preview clones (or at least a separate database role and network path), so preview code cannot reach production data even if a branch is malicious or buggy
The Caddyfile and CI flow stay the same; only the deploy target and database connection change.
For the building blocks, see dynamic modules, on-demand TLS, and branchable.
