Un recorrido por la historia de deployment de Fitz — healthchecks, secrets como tipos opacos, observability con OpenTelemetry, Dockerfiles autogenerados y
fitz deploy. Production-ready no es una checklist, es sintaxis.
La historia de deployment que la mayoría de los lenguajes no cuenta
El primer 80% de un servicio es divertido: rutas, types, lógica de negocio, tests. El último 20% es la parte que efectivamente entrega la cosa, y ahí es donde todo el mundo pega con cinta cinco herramientas distintas:
- Una librería de healthcheck estilo
psutilporque Kubernetes quiere/healthz. -
python-decoupleopydantic-settingspara env vars, más tu propia claseSecretque con suerte no termina en logs. -
opentelemetry-instrumentation-fastapimásopentelemetry-exporter-otlp-proto-httpmásopentelemetry-instrumentation-sqlalchemymás cualquiera sea la combinación correcta de versiones este mes. - Un Dockerfile copiado de un post de blog, con tres cosas mal para tu setup.
- Un
docker-compose.ymlque está bien excepto por el env var que tiene que venir de.env.production. - Un
Makefileojustfilecon los comandos reales, o un YAML de CI que hace el equivalente.
Llevo diez años haciendo esto en cada proyecto. Es la parte donde el lenguaje deja de ayudarte. Fitz se niega a hacer eso. Deployment está en el lenguaje.
Acá está el stack completo de producción en Fitz, y qué reemplaza cada pieza.
Health checks como decoradores
@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,
}
}
/healthz y /readyz se auto-montan en el router HTTP. Nada para importar, ninguna librería para configurar. El return value de la función determina el HTTP status (200 si true, 503 si false). Kubernetes contento.
El compilador también hace cumplir el shape: la función retorna Bool o Result<Bool>, toma un DbConn si lo necesita (el runtime lo inyecta). Si te olvidás del @readyz entero, el endpoint de readiness simplemente no existe — no hay librería que "casi configures" mal.
En 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) # acordate de montarlo
En Fitz:
@readyz
async fn readiness(db: DbConn) -> Bool {
return match db.exec("SELECT 1").await { Ok(_) => true, Err(_) => false }
}
Eso es todo. Misma semántica, sin librería, sin paso de mounting, sin docs que tenés que volver a leer.
Secrets como tipos opacos
El bug que más vi en código de producción: alguien loguea la password, la API key, el JWT secret. El logger entrega a Loki / Splunk / Sentry / lo que sea. El secret está ahora en logs de producción. Todo el mundo está de acuerdo en que esto es malo. Nadie tiene una buena respuesta en las librerías estándar — os.environ["FOO"] es solo un string. pydantic.SecretStr existe pero tenés que opt-in en todos lados y la gente se olvida.
Fitz hace de los secrets un tipo de primera clase:
let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")
let DB_URL: Secret<Str> = secret("DATABASE_URL")
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")
Tres cosas siguen del hecho de que Secret<T> sea un tipo:
-
print(JWT_SECRET)imprime `""`*. El trait Display redacta el valor. -
La serialización JSON redacta.
log.info("config", { jwt: JWT_SECRET })envía"jwt": "***"a tu sistema de observability. -
Hay exactamente una forma de exponer el valor:
JWT_SECRET.expose(). Explícita, grepeable. El code review puede auditar cada call site en segundos.
config("LOG_LEVEL", "info") es el hermano no-secret. Mismo lookup de env var, mismo default, tipo Str plano — sin redacción porque no es sensible. El sistema de tipos hace la distinción obvia en lugar de depender de convenciones de nombres.
Combinado con el migrator (Parte 2), los secrets de Postgres viven en Secret<Str> desde el momento en que entran al binario hasta que se entregan al driver. No hay variable string con la password dando vueltas.
Observability con un solo env var
Tracing y métricas son las partes de observability de producción que todo el mundo quiere y nadie quiere configurar. El SDK Python de OpenTelemetry actualmente te pide que:
- Instales
opentelemetry-api,opentelemetry-sdk, el exporter para tu protocolo, y un paquete de instrumentation por cada librería que uses. - Configures el tracer provider, el meter provider, los resource attributes, el sampler.
- Lo enganches en un hook de startup.
- Esperes que ninguna de las versiones de esos paquetes se haya roto entre releases de Python.
En Fitz:
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 ./mybin
Esa es la integración. Cada request HTTP abre un span que exporta a OTLP. El span lleva http.method, http.target (template de la ruta, no path con params), http.status_code, duration_ms. El trace_id y span_id se propagan a cada log.info(...) adentro del handler — cuando grepeás Jaeger por trace_id, encontrás cada log line relacionado en todos los servicios al instante.
Cuando el env var no está seteado: cero overhead, cero llamadas de red, ninguna tarea de exporter corriendo.
Podés opt-out por ruta:
@server(observability=false)
fn main() => 0
Podés agregar spans explícitos adentro de la lógica de negocio:
@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))
}
@trace abre un tracing::info_span!. @metric registra un histograma <name>_duration_seconds y un counter <name>_calls_total, populados al hacer drop del scope de la función — funciona con paths return explícitos, sin código muerto.
Las métricas también se exponen en /metrics (formato Prometheus) si lo activás:
@server(prometheus=true)
fn main() => 0
Para tiendas OpenTelemetry-native, el exporter OTLP envía también las métricas — ambos backends funcionan.
Logs estructurados con auto-correlación
log.info("user.signup", {
user_id: user.id,
email: user.email,
plan: "free",
})
Eso emite una línea JSON a stderr con timestamp, level, msg, los fields explícitos, y automáticamente el trace_id/span_id del request HTTP actual. Sin llamada a tracer.get_current_span(). El wrapper HTTP setea el contexto; el logger lo lee. Correlacionás logs y traces por trace_id sin pensar en eso.
Si valores Secret<T> aparecen en los kwargs, se redactan antes de serializar. No hay forma de loguear accidentalmente un secret salvo escribir .expose() explícito.
Pretty-print en dev, JSON en producción:
$ ./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..."}
El happy path de Loki/Datadog/Splunk es el mismo JSON estés usando OpenTelemetry o no. Ningún agente re-parsea prefijos.
Feature flags como decorador
Los feature flags estilo unleash/launchdarkly son normalmente una llamada de servicio por evaluación. Para la mayoría de los proyectos, la respuesta correcta es mucho más simple: una flag en tu config, un override por env var, un 404 si la flag está off.
@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -> Receipt { ... }
Dos fuentes:
- Sección
[flags]enfitz.toml— defaults compile-time horneados al binario. - Env vars
FITZ_FLAG_<NAME>— override runtime sin recompilar.
Default es false (fail-safe). Cuando la flag está off, el handler HTTP/WS retorna 404 — y la ruta está gateada antes de que corran los middlewares y la auth, así que no gastás ciclos en algo que el user no puede alcanzar de todas formas.
Adentro de la lógica de negocio:
if flag("show-experimental-banner") {
show_banner()
}
flags.list() enumera flags conocidas (config + env). flags.is_enabled("name") es el alias.
El modelo no está tratando de reemplazar a LaunchDarkly. Está tratando de eliminar la excusa para commitear if user.id == 42 y gatear features para testing. Servicio de feature flags real cuando necesitás targeting, porcentajes, audit log. Flags built-in cuando solo querés un kill switch.
Dockerfile generado de tu AST
fitz docker init
Lee tu main.fitz y escribe tres archivos:
-
Dockerfile— multi-stage. El builder usa la imagen toolchain de Fitz. La stage de runtime esgcr.io/distroless/cc-debian12(opython:3.12-slim-bookwormsi hay unfrom python import ...en tu código — distroless no puede hostear CPython). -
.dockerignore— defaults que matchean los smell tests (target/,.git/,.env*,__pycache__/). -
docker-compose.yml— tu app más la infraestructura que necesita.db.connect(...)en tu código agregapostgres:16-alpinecon healthcheck y volumenpgdata.@server(8080)seteaEXPOSE 8080.@cronagregarestart: unless-stopped. Healthcheck contra/healthzporque hay un decorador@healthz.
La detección es AST-only (~50ms). No ejecuta tu programa, no toca el disco, no probe ports. Lee el árbol sintáctico, busca decoradores y calls landmarks, llena el template correcto.
Esta es la parte de deployment que tuve mal en cada proyecto: el Dockerfile que está casi bien pero no tiene libpq para Postgres, el compose que está casi bien pero montea el volumen equivocado. fitz docker init lo agarra bien la primera vez porque el AST le dice qué necesitás.
Los archivos se commitean. Editalos cuando los necesites:
$ fitz docker init # generar
$ # editás Dockerfile, editás docker-compose.yml — son archivos normales
$ git add Dockerfile docker-compose.yml .dockerignore
$ git commit
fitz docker build es el wrapper fino que corre docker build -t <pkg-name>:latest . con el working directory correcto.
fitz deploy
# Build de imagen y push al registry (saltá el push con --no-push).
fitz deploy docker --tag mycorp/api:v1
# Levantá el stack compose local (con -d por default; --no-detach para foreground).
fitz deploy compose
Estos no son herramientas nuevas. Son wrappers finos sobre docker build/docker push y docker compose up. El punto no es inventar un sistema de deploy; el punto es que el comando de deploy existe en la misma toolchain que fitz build. No tenés que mantener un deploy.sh al lado de tu código.
Targets en el MVP: docker y compose. Targets explícitamente fuera del MVP: fly, railway, k8s. Para esos, corré flyctl deploy / railway up / kubectl apply directo — ya son buenas herramientas, Fitz no necesita re-envolverlas. Si aparece demanda por un target específico más adelante, se puede agregar — el helper crate son ~430 líneas.
Cómo se ve end-to-end
Un servicio real en Fitz hoy:
@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> { /* verify JWT */ }
@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?
}
Lo deployás:
fitz docker init # genera Dockerfile + compose con postgres + healthcheck
git add . && git commit -m "deploy setup"
fitz deploy docker --tag mycorp/api:v1
En producción:
docker run --rm \
-e DATABASE_URL=postgres://... \
-e JWT_SECRET=... \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
-p 8080:8080 \
mycorp/api:v1
Ese binario:
- Auto-montea
/healthz,/readyz,/openapi.json,/docs,/metrics,/asyncapi.json. - Exporta cada request HTTP como span OpenTelemetry con
trace_idpropagado a logs. - Valida JWTs con passwords hasheadas Argon2id.
- Corre cleanup scheduled con retry persistente sobre las tablas
fitz_cron_jobs/fitz_cron_runs. - Redacta cada
Secret<T>de logs, JSON responses, y fields estructurados. - Maneja SIGTERM gracefully (drainando requests, después exit).
- Compila a ~18 MB de binario nativo.
Escribiste ~50 líneas de código de negocio. El resto es el lenguaje haciendo lo que el lenguaje debería hacer.
Lo que todavía no está acá (honesto)
Te dije la verdad en la Parte 1: un solo dev. La historia de deployment tiene gaps conocidos:
-
fitz deploy fly/fitz deploy railway/fitz deploy k8sno están construidos. Usáflyctl deployorailway upokubectl applydirecto. Los CLIs nativos son excelentes — Fitz no necesita re-envolverlos hoy. - Config de sidecar log shipping en el compose generado. Si querés Fluent Bit / Vector / Loki Promtail cableado, editá el compose a mano. El autogen cubre el shape del programa, no el backend de observability.
-
Limits de CPU/memoria en compose. El MVP no los setea — deploys de producción (compose stack a un server) deberían agregar
deploy.resources.limits. -
SBOM / firma de imagen. No se autogenera. La imagen es output de
docker build. Firmá concosignsi lo necesitás.
Todo lo demás de arriba está en v0.15.0 hoy, con tests, con paridad bit-a-bit fitz run ↔ fitz build, con ejemplos en los docs.
Por qué importa
El split 80/20 que describí al principio — 80% código divertido, 20% pegoteo de producción — es un impuesto sobre cada lenguaje diseñado antes de 2015. Los lenguajes que diseñaron para producción desde el día uno (Go es el ejemplo obvio) cambiaron otras cosas para llegar ahí. Fitz está tratando de mantener el feel gradual-typed, expression-rich, async-first de Python — y aún así taxar producción con cero peso extra.
Si pasaste por una semana de deploy y sentiste "esto no debería requerir cinco pestañas de Stack Overflow", ese es el sentimiento al que estoy construyendo para eliminar.
Probalo:
# Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh
# Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex
# Reabrí la terminal, después:
fitz new mi-api-prod --http
cd mi-api-prod
# Editá main.fitz para sumar @healthz, @table, una ruta HTTP
fitz docker init
fitz deploy compose
Para VSCode (recomendado — hover con tipos, autocomplete, signature help): bajá el fitz-lang-<plataforma>.vsix desde la página de releases y code --install-extension fitz-lang-<plataforma>.vsix --force. El Language Server viene incluido.
Vas a tener un servicio con healthchecks, observability, y Docker compose en tu proyecto en menos de cinco minutos. Avisame qué se rompió.
Repo: github.com/Thegreekman76/fitz
Docs y curso: thegreekman76.github.io/fitz
Capítulo de la guía sobre deployment: thegreekman76.github.io/fitz/guide/#35-deployment
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md
Issues: github.com/Thegreekman76/fitz/issues
Hasta la próxima.
Top comments (0)