Skip to content

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 by pachifast new project. new project creates the project skeleton only; run new pipeline inside 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: true in YAML does not turn it back on; use -e PACHIFAST_DEBUG=true for 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, so uv.lock must exist and be committed. Run uv lock first, 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 no locale command (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=8000 and PACHIFAST_DEBUG=false.
  • Health check. HEALTHCHECK runs a small python one-liner (no curl needed) that requests http://127.0.0.1:$PACHIFAST_PORT/api/health/ and fails when the response is not 2xx.

Before using the pipelines

  1. Commit uv.lock. See above.
  2. Make pachifast resolvable 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.
  3. Pin versions. The workflows use the major tags actions/checkout@v4, docker/setup-buildx-action@v3, docker/login-action@v3, docker/metadata-action@v5 and docker/build-push-action@v6. The Dockerfile uses ghcr.io/astral-sh/uv:latest and the GitLab pipeline docker: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.toml and uv.lock first, then the project folder (COPY . .). A [tool.uv.sources] entry that points to a local path outside the Docker build context (for example pachifast = { 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 ARG or ENV in 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 forge X-Forwarded-For and X-Forwarded-Proto.

If the frontend is served from a different origin, remember CORS: see Frontend integration.