Deployment¶
This page shows how to run a pachifast project in production: the serve command, the container image and CI pipelines generated by pachifast new pipeline, runtime configuration, migrations as a release step and reverse proxy settings.
Note: the pipelines are generated by
pachifast new pipeline, not bypachifast new project.new projectcreates the project skeleton only; runnew pipelineinside it afterwards.
Run in production with pachifast serve¶
pachifast dev is for development only (auto-reload). In production use serve:
pachifast serve # host and port from the configuration
pachifast serve --workers 4 --port 8080
| Option | Description |
|---|---|
--base-dir |
Project root (default .). The process changes into it so .env and the YAML file are found. |
--app |
ASGI import string (default main:app). |
--host, --port |
Bind address. When omitted: environment variables, .env, YAML, then 127.0.0.1 and 8000. |
--workers N |
Number of worker processes (default 1, minimum 1). |
--proxy-headers/--no-proxy-headers |
Trust X-Forwarded-For and X-Forwarded-Proto (default: on). |
--forwarded-allow-ips |
Comma separated IPs allowed to send those headers. |
--log-level |
uvicorn log level: critical, error, warning, info (default), debug, trace. |
See reference/cli.md for the full command reference.
Runtime configuration¶
The image contains no secrets and no .env file: everything arrives at runtime. Precedence, highest first: CLI options, environment variables, .env, the YAML file (pachifast.yaml), defaults. In a container you normally use environment variables (docker run -e, environment: in Compose, or your orchestrator's secrets). See Configuration and the settings reference for every setting.
| Variable | Purpose |
|---|---|
PACHIFAST_SECRET_KEY |
Secret used to sign tokens. Required, at least 32 characters; never commit it. |
PACHIFAST_AUTH_DATABASE_URL |
Authentication database, for example postgresql+asyncpg://user:password@db:5432/app. |
PACHIFAST_HOST, PACHIFAST_PORT |
Bind address. The image sets 0.0.0.0 and 8000. |
PACHIFAST_DEBUG |
The image sets false. With true, an unhandled error shows the traceback to the client and application logs drop to DEBUG. |
PACHIFAST_LOG_LEVEL |
Application log level (CRITICAL, ERROR, WARNING, INFO, DEBUG). When empty: INFO, or DEBUG if PACHIFAST_DEBUG=true. serve --log-level only affects uvicorn's logs. |
PACHIFAST_CONFIG_FILE |
Path of a YAML configuration file (useful with a mounted volume). |
PACHIFAST_CACHE_URL |
Empty: per-process in-memory cache. redis://...: shared Redis cache (needs pachifast[redis]). See Cache. |
Note: with debug off, unhandled errors answer with the uniform JSON body (
500,Internal Server Error) and the traceback stays in the log. Because environment variables override the YAML file,debug: truein YAML does not turn it back on; use-e PACHIFAST_DEBUG=truefor a one-off investigation.Warning: set the port with
PACHIFAST_PORT. The image health check reads that variable; if you change the port only in the YAML file, the check probes the wrong port and the container is reported as unhealthy.
Switching routes off in an emergency¶
Routers and single routes can be disabled by configuration, without code changes or a rebuild: restart the container with one more variable.
# whole routers (by name) and single routes, for every module
PACHIFAST_DISABLE_ROUTERS=billing,reports
PACHIFAST_DISABLE_ROUTES="/billing/invoices,PUT /orders/{order_id}"
# the same for the built-in (core) routers
PACHIFAST_DISABLE_CORE_ROUTERS=permissions,admin
PACHIFAST_DISABLE_CORE_ROUTES=/user/settings
Entries are comma separated. Matching rules are the same as for disable_core_*, described in Modules. Disabled routes are not mounted and disappear from the OpenAPI documentation. The same keys work in pachifast.yaml (disable_routers: [billing]), but the environment variable is the fastest route in an emergency because it does not require editing a file inside the image.
Migrations as a release step¶
Do not run migrations when the web server starts: with several replicas or workers they would run concurrently. Make them a separate step before the new version goes live:
pachifast migrations upgrade --target-name auth
Run it from the same image as an alternative command, for example docker run --rm <image> pachifast migrations upgrade --target-name auth (with the same environment variables as the service), or as an init container or release job in your orchestrator. The migrations/ folder is part of the image. Create revisions in development (pachifast migrations revision ... --autogenerate) and commit them. See Database and migrations.
Health check¶
GET /api/health/ answers without authentication. The image HEALTHCHECK uses it (with python, no curl, on the port from PACHIFAST_PORT); you can also use it as a liveness or readiness probe.
Generate the image and CI pipelines¶
pachifast new pipeline # Dockerfile, .dockerignore, GitHub Actions, GitLab CI and Gitea Actions
pachifast new pipeline --provider github # GitHub Actions only
pachifast new pipeline --provider gitlab --python-version 3.13
pachifast new pipeline --provider gitea # Gitea Actions only (the registry of the Gitea instance)
pachifast new pipeline --index-name private # the build uses a private uv index (see below)
pachifast new pipeline --force # overwrite existing files
| Option | Description |
|---|---|
--provider |
github, gitlab, gitea or all (default). |
--python-version |
Base image version as major.minor (default 3.12). |
--index-name |
Name of a private uv index declared in pyproject.toml; its credentials are passed to the build as secrets. |
--ca-cert |
Path inside the project of a .crt or .pem certificate authority trusted during the build. |
--force |
Overwrite files that already exist. |
Without --force, existing files are left untouched and listed as "Skipped". The command generates:
| File | Contents |
|---|---|
Dockerfile |
Two-stage build. Builder: uv sync --frozen --no-dev (reproducible install from uv.lock), then pachifast locale check. Runtime: only the virtual environment on python:<version>-slim, a non-root user, EXPOSE 8000, HEALTHCHECK, CMD ["pachifast", "serve"]. |
.dockerignore |
Excludes .env, *.env, .git, .venv, tests, SQLite databases and CI files. It does not exclude migrations/. |
.github/workflows/docker-image.yml |
On push to main and on v* tags: Buildx build and push to ghcr.io/<owner>/<repo> with GITHUB_TOKEN (no secret to configure). Tags: branch, semver, short sha, and latest on the default branch. Uses the gha build cache. |
.gitea/workflows/docker-image.yml |
Same triggers for Gitea Actions. The registry is the Gitea instance itself (host derived from GITHUB_SERVER_URL, nothing hardcoded), image <host>/<owner>/<repo>. Logs in with the secret REGISTRY_TOKEN (an access token with the write:package scope). No gha cache, since that is a GitHub service. Optional variables BUILD_ADD_HOSTS and BUILDX_DRIVER (see below). |
.gitlab-ci.yml |
A build-image job using Docker-in-Docker, pushing to $CI_REGISTRY_IMAGE: latest and the short sha on the default branch, the Git tag name on tags. |
The Dockerfile in detail¶
- Reproducible build. Dependencies are installed with
uv sync --frozen, souv.lockmust exist and be committed. Runuv lockfirst, otherwise the build fails (the command warns you if the file is missing). - Catalog check. The builder stage runs
pachifast locale check. The build fails when the message catalogs disagree between languages, for example[it] missing 1 message(s): billing.empty. The step is skipped if the installed pachifast has nolocalecommand (older versions). See Errors and i18n. - Non-root runtime. The final image runs as the system user
app(uid 10001). uv is not part of it. - No secrets in the image. Configuration is passed as environment variables at runtime. The image sets
PACHIFAST_HOST=0.0.0.0,PACHIFAST_PORT=8000andPACHIFAST_DEBUG=false. - Health check.
HEALTHCHECKruns a smallpythonone-liner (nocurlneeded) that requestshttp://127.0.0.1:$PACHIFAST_PORT/api/health/and fails when the response is not 2xx.
Before using the pipelines¶
- Commit
uv.lock. See above. - Make
pachifastresolvable by uv. pachifast is on public PyPI, so nothing is needed for the standard case. Only if you depend on a private fork or on private packages does the CI need a reachable package index, for example a private registry with--index-name(below), or a[tool.uv.sources]entry. - Pin versions. The workflows use the major tags
actions/checkout@v4,docker/setup-buildx-action@v3,docker/login-action@v3,docker/metadata-action@v5anddocker/build-push-action@v6. The Dockerfile usesghcr.io/astral-sh/uv:latestand the GitLab pipelinedocker:latest/docker:dind. Pinning them (and the uv image) gives repeatable builds.
Warning: known limitation with uv path sources. The builder stage copies only
pyproject.tomlanduv.lockfirst, then the project folder (COPY . .). A[tool.uv.sources]entry that points to a local path outside the Docker build context (for examplepachifast = { path = "../pachifast" }) cannot be resolved inside the container, and the build fails. Use a package index or a git URL for images built in CI, and keep path sources for local development only.
Private package index¶
This section is optional: it applies only when the project depends on packages that are not on public PyPI (private modules, or a private fork of pachifast).
Declare the index in pyproject.toml without credentials, then generate the pipeline with the same name:
[[tool.uv.index]]
name = "private"
url = "https://<host>/<path>/simple"
# locally, with UV_INDEX_PRIVATE_USERNAME / UV_INDEX_PRIVATE_PASSWORD exported
uv lock
pachifast new pipeline --index-name private
The Dockerfile passes the credentials to uv sync as build secrets (RUN --mount=type=secret,...,env=UV_INDEX_PRIVATE_USERNAME). They exist only while the command runs and never end up in an image layer. The pipelines read them from:
| CI | Where to set them |
|---|---|
| GitHub Actions | Repository secrets UV_INDEX_USERNAME and UV_INDEX_PASSWORD. |
| Gitea Actions | Secrets UV_INDEX_USERNAME and UV_INDEX_PASSWORD; when they are not defined, REGISTRY_USERNAME and REGISTRY_TOKEN are used (the same Gitea hosts the index). REGISTRY_TOKEN is needed anyway to push the image. |
| GitLab CI | CI/CD variables UV_INDEX_USERNAME and UV_INDEX_PASSWORD (masked and protected). |
The command warns you when pyproject.toml does not declare an index with that name. On Gitea, reading from the registry needs a token with the read:package scope.
Registry served with a private certificate authority¶
Inside the container uv does not know your CA, and the build stops with invalid peer certificate: UnknownIssuer. Put the CA certificate (public, not a secret) in the project and declare it:
pachifast new pipeline --index-name private --ca-cert certs/private-ca.crt
The builder stage installs it (COPY + update-ca-certificates, UV_SYSTEM_CERTS=1) only while dependencies are installed; the final image does not contain it. The path must be relative, inside the project, end in .crt or .pem, and must not be excluded by .dockerignore. A local uv lock must also trust the CA (usually it is already in the system store: uv lock --system-certs).
Registries with a custom host name¶
A registry host name defined only in your machine's /etc/hosts cannot be resolved inside the container, and docker build stops with client error (Connect). Locally, add --add-host <registry-host>:<ip>.
On a Gitea runner the build container does not see the runner host's /etc/hosts either. Set the repository (or organization) variable BUILD_ADD_HOSTS to <registry-host>:<ip> (comma separated for more); the generated Gitea workflow passes it to the build as add-hosts.
Pushing the image to a registry with a private CA¶
The default docker-container Buildx builder runs in its own container and does not trust your CA, so the final push fails with x509: certificate signed by unknown authority, even though --ca-cert made the dependency install work. Set the variable BUILDX_DRIVER to docker: the build and the push then go through the runner's Docker daemon, which trusts the CA when the host is configured for it.
Generated pipelines¶
The GitHub Actions and GitLab CI pipelines are a starting point: check the action versions and the runner labels against your own instance.
Example¶
docker build -t my-service .
docker run --rm -p 8000:8000 \
-e PACHIFAST_SECRET_KEY="$(openssl rand -hex 32)" \
-e PACHIFAST_AUTH_DATABASE_URL="postgresql+asyncpg://user:password@db:5432/app" \
-e PACHIFAST_DISABLE_CORE_ROUTERS=permissions \
my-service
# migrations as a release step, with the same variables
docker run --rm \
-e PACHIFAST_SECRET_KEY=... -e PACHIFAST_AUTH_DATABASE_URL=... \
my-service pachifast migrations upgrade --target-name auth
Warning: never pass secrets with
ARGorENVin the Dockerfile and never copy them into the image: they would stay in the layers and the image history.
Behind a reverse proxy¶
Behind a reverse proxy or load balancer, uvicorn must trust the proxy's forwarded headers to see the real client IP and the https scheme. Without --forwarded-allow-ips, uvicorn trusts only 127.0.0.1 (or the FORWARDED_ALLOW_IPS environment variable): a prudent default, but a proxy in another container is then not trusted.
pachifast serve --forwarded-allow-ips "10.0.0.0/8" # the proxy's IP or network
pachifast serve --forwarded-allow-ips "*" # only if the app is reachable exclusively through the proxy
Warning: with
*and the port exposed directly, anyone can forgeX-Forwarded-ForandX-Forwarded-Proto.
If the frontend is served from a different origin, remember CORS: see Frontend integration.