DEV Community

Cover image for From repo to production in one command: how Fitz makes deployment a language feature
Martin Palopoli
Martin Palopoli

Posted on

From repo to production in one command: how Fitz makes deployment a language feature

A walk through the deployment story of Fitz — healthchecks, secrets as opaque types, OpenTelemetry observability, autogenerated Dockerfiles, and fitz deploy. Production-ready isn't a checklist, it's syntax.

The deployment story most languages don't tell

The first 80% of a service is fun: routes, types, business logic, tests. The last 20% is the part that ships the thing, and that's where everyone tapes together five different tools:

  • Some psutil-style healthcheck library because Kubernetes wants /healthz.
  • python-decouple or pydantic-settings for env vars, plus your own Secret class that hopefully doesn't end up in logs.
  • opentelemetry-instrumentation-fastapi plus opentelemetry-exporter-otlp-proto-http plus opentelemetry-instrumentation-sqlalchemy plus whatever the right combo of versions is this month.
  • A Dockerfile copy-pasted from a blog post, with three things wrong for your setup.
  • A docker-compose.yml that's right except for the env var that has to come from .env.production.
  • A Makefile or justfile with the actual commands, or a CI YAML that does the equivalent.

I've done this every project for ten years. It's the part where the language stops helping you. Fitz refuses to do that. Deployment is in the language.

Here's the full production stack in Fitz, and what each piece replaces.

Health checks as decorators

@server(43928)
fn main() => 0

@healthz
fn liveness() -> Bool => true

@readyz
async fn readiness(db: DbConn) -> Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) => true,
        Err(_) => false,
    }
}
Enter fullscreen mode Exit fullscreen mode

/healthz and /readyz auto-mount on the HTTP router. There's nothing to import, no library to configure. The return value of the function determines the HTTP status (200 if true, 503 if false). Kubernetes is happy.

The compiler also enforces shape: the function returns Bool or Result<Bool>, takes a DbConn if it needs one (the runtime injects it). If you forget @readyz entirely, the readiness endpoint just doesn't exist — there's no library to "almost configure" wrong.

In Python:

# requirements.txt: starlette-healthcheck, pyhealthcheck, ...
from healthcheck import HealthCheck
health = HealthCheck()
def db_check():
    try:
        db.execute("SELECT 1")
        return True, "ok"
    except Exception as e:
        return False, str(e)
health.add_check(db_check)
app.add_route("/healthz", health.run)  # remember to mount it
Enter fullscreen mode Exit fullscreen mode

In Fitz:

@readyz
async fn readiness(db: DbConn) -> Bool {
    return match db.exec("SELECT 1").await { Ok(_) => true, Err(_) => false }
}
Enter fullscreen mode Exit fullscreen mode

That's it. Same semantics, no library, no mount step, no docs you have to re-read.

Secrets as opaque types

The bug I've seen the most in production code: somebody logs the password, the API key, the JWT secret. The logger ships to Loki / Splunk / Sentry / whatever. The secret is now in production logs. Everyone agrees this is bad. Nobody has a good answer in stock libraries — os.environ["FOO"] is just a string. pydantic.SecretStr exists but you have to opt in everywhere and people forget.

Fitz makes secrets a first-class type:

let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")
let DB_URL: Secret<Str> = secret("DATABASE_URL")
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")
Enter fullscreen mode Exit fullscreen mode

Three things follow from Secret<T> being a type:

  1. print(JWT_SECRET) prints `""`*. The Display trait redacts the value.
  2. JSON serialization redacts. log.info("config", { jwt: JWT_SECRET }) ships "jwt": "***" to your observability system.
  3. There's exactly one way to expose the value: JWT_SECRET.expose(). Explicit, grep-able. Code review can audit every call site in seconds.

config("LOG_LEVEL", "info") is the non-secret sibling. Same env var lookup, same default, plain Str type — no redaction because it's not sensitive. The type system makes the distinction obvious instead of relying on naming conventions.

Combined with the migrator (Part 2), Postgres secrets live in Secret<Str> from the moment they enter the binary until they're handed to the driver. There's no string variable holding the password sitting around.

Observability with one env var

Tracing and metrics are the parts of production observability that everyone wants and nobody wants to set up. The Python OpenTelemetry SDK currently requires you to:

  1. Install opentelemetry-api, opentelemetry-sdk, the exporter for your protocol, and one instrumentation package per library you use.
  2. Configure the tracer provider, the meter provider, the resource attributes, the sampler.
  3. Wire it up in a startup hook.
  4. Hope none of the versions of those packages broke between Python releases.

In Fitz:

OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 ./mybin
Enter fullscreen mode Exit fullscreen mode

That's the integration. Every HTTP request opens a span that exports to OTLP. The span carries http.method, http.target (route template, not path with params), http.status_code, duration_ms. The trace_id and span_id are propagated to every log.info(...) inside the handler — when you grep Jaeger by trace_id, you instantly find every related log line across services.

When the env var is not set: zero overhead, zero network calls, no exporter task running.

You can opt out per route:

@server(observability=false)
fn main() => 0
Enter fullscreen mode Exit fullscreen mode

You can add explicit spans inside business logic:

@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -> Result<Receipt> {
    let validated = validate(order)?
    let charged = charge(validated).await?
    return Ok(receipt_for(charged))
}
Enter fullscreen mode Exit fullscreen mode

@trace opens a tracing::info_span!. @metric registers a <name>_duration_seconds histogram and a <name>_calls_total counter, populated on drop of the function scope — works with explicit return paths, no dead code.

Metrics also expose at /metrics (Prometheus format) if you opt in:

@server(prometheus=true)
fn main() => 0
Enter fullscreen mode Exit fullscreen mode

For OpenTelemetry-native shops, the OTLP exporter sends metrics too — both backends work.

Structured logs with auto-correlation

log.info("user.signup", {
    user_id: user.id,
    email: user.email,
    plan: "free",
})
Enter fullscreen mode Exit fullscreen mode

That emits a JSON line to stderr with timestamp, level, msg, the explicit fields, and automatically the trace_id/span_id of the current HTTP request. No tracer.get_current_span() call. The HTTP wrapper sets the context; the logger reads it. You correlate logs and traces by trace_id without thinking about it.

If Secret<T> values appear in the kwargs, they're redacted before serialization. There is no way to accidentally log a secret short of writing .expose() explicitly.

Pretty-printed in dev, JSON in production:

$ ./mybin
2026-06-05 14:23:11 INFO http.access method=GET target=/users/{id} status=200 duration_ms=12 trace_id=ab12cd34...
2026-06-05 14:23:11 INFO user.lookup user_id=42 found=true

$ FITZ_LOG_FORMAT=json ./mybin
{"timestamp":"2026-06-05T14:23:11Z","level":"INFO","msg":"http.access","method":"GET","target":"/users/{id}","status":200,"duration_ms":12,"trace_id":"ab12cd34..."}
Enter fullscreen mode Exit fullscreen mode

The Loki/Datadog/Splunk happy path is the same JSON whether you're using OpenTelemetry or not. No agent re-parses prefixes.

Feature flags as a decorator

Feature flags from unleash/launchdarkly are usually a service call per evaluation. For most projects, the right answer is much simpler: a flag in your config, an env var override, a 404 if the flag is off.

@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -> Receipt { ... }
Enter fullscreen mode Exit fullscreen mode

Two sources:

  • [flags] section in fitz.toml — compile-time defaults baked into the binary.
  • FITZ_FLAG_<NAME> env vars — runtime override without recompiling.

Default is false (fail-safe). When the flag is off, the HTTP/WS handler returns 404 — and the route is gated before middleware and auth run, so you don't waste cycles on something the user can't reach anyway.

Inside business logic:

if flag("show-experimental-banner") {
    show_banner()
}
Enter fullscreen mode Exit fullscreen mode

flags.list() enumerates known flags (config + env). flags.is_enabled("name") is the alias.

The model isn't trying to replace LaunchDarkly. It's trying to remove the excuse for committing if user.id == 42 to gate features for testing. Real feature flag service when you need targeting, percentages, audit log. Built-in flags when you just want a kill switch.

Dockerfile generated from your AST

fitz docker init
Enter fullscreen mode Exit fullscreen mode

Reads your main.fitz and writes three files:

  • Dockerfile — multi-stage. The builder uses the Fitz toolchain image. The runtime stage is gcr.io/distroless/cc-debian12 (or python:3.12-slim-bookworm if there's a from python import ... in your code — distroless can't host CPython).
  • .dockerignore — defaults that match the smell tests (target/, .git/, .env*, __pycache__/).
  • docker-compose.yml — your app plus the infrastructure it needs. db.connect(...) in your code adds postgres:16-alpine with a healthcheck and a pgdata volume. @server(8080) sets EXPOSE 8080. @cron adds restart: unless-stopped. Healthcheck against /healthz because there's an @healthz decorator.

The detection is AST-only (~50ms). It doesn't run your program, doesn't poke at the disk, doesn't probe ports. It reads the syntax tree, looks for landmark decorators and calls, fills in the right template.

This is the part of deployment that I've gotten wrong every project: the Dockerfile that's almost right but doesn't have the libpq for Postgres, the compose that's almost right but mounts the wrong volume. fitz docker init gets it right the first time because the AST tells it what you need.

The files are committed. Edit them when you need to:

$ fitz docker init        # generate
$ # edit Dockerfile, edit docker-compose.yml — these are normal files
$ git add Dockerfile docker-compose.yml .dockerignore
$ git commit
Enter fullscreen mode Exit fullscreen mode

fitz docker build is the thin wrapper that runs docker build -t <pkg-name>:latest . with the right working directory.

fitz deploy

# Build the image and push to a registry (skip the push with --no-push).
fitz deploy docker --tag mycorp/api:v1

# Bring up the compose stack locally (with -d by default; --no-detach for foreground).
fitz deploy compose
Enter fullscreen mode Exit fullscreen mode

These are not new tools. They're thin wrappers over docker build/docker push and docker compose up. The point isn't to invent a deploy system; the point is that the deploy command exists in the same toolchain as fitz build. You don't have to maintain a deploy.sh next to your code.

Targets in the MVP: docker and compose. Targets explicitly not in the MVP: fly, railway, k8s. For those, run flyctl deploy / railway up / kubectl apply directly — they're already good tools, Fitz doesn't need to re-wrap them. If demand appears for a specific target later, it can be added — the helper crate is ~430 lines.

What it looks like end-to-end

A real service in Fitz today:

@server(8080, prometheus=true)
fn main() => 0

let DB_URL: Secret<Str> = secret("DATABASE_URL")
let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")
let db = db.connect(DB_URL.expose())

@healthz
fn liveness() -> Bool => true

@readyz
async fn readiness() -> Bool {
    return match db.exec("SELECT 1").await { Ok(_) => true, Err(_) => false }
}

@table("users")
type User { @primary id: Int, email: Str, name: Str, role: Str }

@auth_provider
fn check_token(headers: Map<Str, Str>) -> Result<User> { /* JWT verify */ }

@authenticated
@get("/me")
fn me(user: User) -> User => user

@trace(name="charge")
@metric(name="charges")
@requires("billing")
@post("/charge")
async fn charge(body: ChargeRequest, user: User) -> Result<Receipt> {
    log.info("charge.attempt", { user_id: user.id, amount: body.amount })
    let receipt = stripe_charge(body).await?
    return Ok(receipt)
}

@cron("0 0 3 * * *", retry={max: 5, backoff: "exponential"}, store=db)
async fn cleanup_expired_tokens() {
    auth.cleanup_expired(db).await?
}
Enter fullscreen mode Exit fullscreen mode

Deploy it:

fitz docker init    # generates Dockerfile + compose with postgres + healthcheck
git add . && git commit -m "deploy setup"
fitz deploy docker --tag mycorp/api:v1
Enter fullscreen mode Exit fullscreen mode

In production:

docker run --rm \
  -e DATABASE_URL=postgres://... \
  -e JWT_SECRET=... \
  -e OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
  -p 8080:8080 \
  mycorp/api:v1
Enter fullscreen mode Exit fullscreen mode

That binary:

  • Auto-mounts /healthz, /readyz, /openapi.json, /docs, /metrics, /asyncapi.json.
  • Exports every HTTP request as an OpenTelemetry span with trace_id propagated to logs.
  • Validates JWTs with Argon2id-hashed passwords.
  • Runs scheduled cleanup with persistent retry over the fitz_cron_jobs/fitz_cron_runs tables.
  • Redacts every Secret<T> from logs, JSON responses, and structured fields.
  • Handles SIGTERM gracefully (draining requests, then exit).
  • Compiles to ~18 MB of native binary.

You wrote ~50 lines of business code. The rest is the language doing what the language should do.

What's not in here yet (honest)

I told you the truth in Part 1: one developer. The deployment story has known gaps:

  • fitz deploy fly / fitz deploy railway / fitz deploy k8s are not built. Use flyctl deploy or railway up or kubectl apply directly. The native CLIs are excellent — Fitz doesn't need to re-wrap them today.
  • Sidecar log shipping config in the generated compose. If you want Fluent Bit / Vector / Loki Promtail wired in, edit the compose by hand. The autogen covers the program shape, not the observability backend.
  • CPU/memory limits in compose. The MVP doesn't set them — production deploys (compose stack to a server) should add deploy.resources.limits.
  • SBOM / image signing. Not auto-generated. The image is just docker build output. Sign with cosign if you need it.

Everything else above is in v0.15.0 today, with tests, with bit-for-bit fitz runfitz build parity, with examples in the docs.

Why it matters

The 80/20 split I described at the top — 80% fun code, 20% production stapling — is a tax on every language designed before 2015. The languages that designed for production from day one (Go is the obvious example) traded other things to get there. Fitz is trying to keep the gradual-typed, expression-rich, async-first feel of Python — and still tax production with zero extra weight.

If you've gone through a deploy week and felt "this should not require five Stack Overflow tabs", that's the feeling I'm building toward removing.

Try it:

# Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh

# Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

# Reopen the terminal, then:
fitz new my-prod-api --http
cd my-prod-api
# Edit main.fitz to add @healthz, @table, an HTTP route
fitz docker init
fitz deploy compose
Enter fullscreen mode Exit fullscreen mode

For VSCode (recommended — hover with types, autocomplete, signature help): grab the fitz-lang-<platform>.vsix from the releases page and code --install-extension fitz-lang-<platform>.vsix --force. The Language Server is bundled.

You'll have a service with healthchecks, observability, and Docker compose in your project in under five minutes. Let me know what broke.

Repo: github.com/Thegreekman76/fitz
Docs and course: thegreekman76.github.io/fitz
Guide chapter on deployment: thegreekman76.github.io/fitz/guide/#35-deployment
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md
Issues: github.com/Thegreekman76/fitz/issues

Until the next one.

Top comments (0)