C6 — Ship it¶
Prerequisite: any project that runs with fitz run (the counter from
C1 is enough), plus docker on your machine.
Objective: turn a LiveViews app into a single native binary and run it — with its Postgres — in containers, using the Admin ABM as the blueprint.
Why it matters: the whole course you ran fitz run. Production is different:
you ship a binary, not a source tree, and there's no runtime to install on the
server — no Node, no Python, no BEAM VM. Deploying a Fitz LiveViews app is closer
to shipping a Go binary than a Rails app: copy the file, run it. The Admin ABM
is the realistic version of that — a binary that also needs a database and a
couple of secrets — so it's the case we walk through here.
1. The binary (and the one new wrinkle)¶
You already met this in C1: fitz build compiles the same program to a
standalone binary.
The binary name comes from [package].name in fitz.toml (admin-abm). It's
bit-for-bit the same app as fitz run — same HTML, same WebSocket frames,
same behavior. It embeds the HTTP server, the WebSocket diff engine, the inline
JS client, and the ORM. Nothing else needs to be on the server.
The new wrinkle versus the C1 counter: the Admin ABM talks to Postgres. The
binary alone isn't the whole deployment — it needs a database next to it, and it
reads its connection string and JWT secret from environment variables (never
hardcoded). That's exactly what containers are good at, so the rest of this
chapter is one Dockerfile + one docker-compose.yml.
This app builds locally because it lives inside the library repo
The Admin ABM depends on fitz_liveviews via a path dependency
(fitz_liveviews = { path = "../.." }) because it sits inside the library's
own repo. Your own app — the one you'd scaffold with
fitz new --template liveviews (C1) — uses a git dependency instead, so
its build is simpler (no repo layout to recreate). We call out where that
changes the Dockerfile below.
2. The Dockerfile — multi-stage, builder → distroless¶
Here's the Admin ABM's Dockerfile in full:
# Dockerfile for the Admin ABM showcase.
#
# The build context is the fitz-liveviews REPO ROOT (see docker-compose.yml:
# `context: ../..`), not this example's folder — because the example depends
# on the library via a path dependency (`fitz_liveviews = { path = "../.." }`),
# which must be inside the build context. We recreate the repo layout in the
# image so that `../..` resolves to the library during `fitz build`.
#
# Multi-stage:
# Stage 1 (builder) — official Fitz image (fitz + Rust toolchain); compiles
# the Fitz source to a native Linux binary.
# Stage 2 (runtime) — distroless: just glibc + libgcc + ca-certificates.
# No shell, no package manager.
#
# Pin the Fitz version for reproducibility:
# docker build --build-arg FITZ_TAG=v0.28.6 ...
# v0.26.1: cross-module `List<Nominal>` codegen
# fix (W19+W20) that lets this app build without importing `Patch` (see
# empleados.fitz — the WS grid uses `WsConn<LiveFrame>` where `LiveFrame`
# has a `List<Patch>` field, and `diff_html` returns `List<Patch>`).
ARG FITZ_TAG=v0.28.6
# ---- Stage 1: builder ------------------------------------------------------
FROM ghcr.io/thegreekman76/fitz:${FITZ_TAG} AS builder
WORKDIR /build
# fitz-liveviews library (the `../..` path dependency).
COPY fitz.toml ./fitz.toml
COPY src ./src
# The admin example itself.
COPY examples/admin/fitz.toml ./examples/admin/fitz.toml
COPY examples/admin/src ./examples/admin/src
# `fitz build` reads the manifest from cwd and emits the binary to
# `target/release/<package.name>` (here: `admin-abm`).
WORKDIR /build/examples/admin
RUN fitz build
# ---- Stage 2: runtime ------------------------------------------------------
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /build/examples/admin/target/release/admin-abm /usr/local/bin/app
# The program binds `@server(3000, "0.0.0.0")`, so `-p 3000:3000` reaches it.
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/app"]
Read it in two halves.
Stage 1 — the builder. It starts from the official Fitz image
(ghcr.io/thegreekman76/fitz:<tag>), which ships the fitz compiler plus the
Rust toolchain it uses under the hood. It copies the source in and runs
fitz build, producing a native Linux binary — even if your dev machine is
Windows or macOS. That's the free cross-compile: the build happens inside a Linux
container.
The one non-obvious part is why the build context is the repo root, not this
folder. Because the example uses the ../.. path dependency, that path must be
inside the Docker build context. So the Dockerfile recreates the repo layout —
it copies the library's fitz.toml+src, then the example's, then cds into
examples/admin and builds. In your own app this disappears: with a git
dependency the context is just your app folder, and the Dockerfile that
fitz docker init generates is a few lines shorter.
Stage 2 — the runtime. It throws away the toolchain and starts fresh from
gcr.io/distroless/cc-debian12 — just glibc, libgcc, and CA certificates. No
shell, no package manager, nothing to attack. It copies only the binary from
the builder stage, EXPOSEs 3000, and sets the binary as the entrypoint. The
final image is tiny because none of the build machinery ships with it.
ARG FITZ_TAG pins the compiler version so a rebuild six months from now
produces the same binary. Override it at build time with
--build-arg FITZ_TAG=v0.28.6.
You don't write this by hand
fitz docker init (Fitz core Phase 12.4) generates a working Dockerfile,
.dockerignore, and docker-compose.yml for any Fitz app — it reads your
@server(N) decorator and wires EXPOSE N + ports: "N:N" automatically.
The Admin ABM's files started there; we only adjusted them for the path-dep
layout and the Postgres service.
3. Compose — the app and its Postgres, one command¶
A binary that needs a database wants a docker-compose.yml that brings both up
together:
# Admin ABM showcase — Postgres + the Fitz app, one command.
#
# docker compose up --build
# → open http://localhost:3000/ (login: admin@fitz.dev / admin1234)
#
# On the first run, Postgres creates the database and runs db/init.sql from
# /docker-entrypoint-initdb.d/ (schema + demo seed). The app waits for the
# database to be healthy, then serves. The app connects per-request; it does
# not touch the schema at boot.
services:
db:
image: postgres:16-alpine
container_name: admin-abm-db
environment:
POSTGRES_USER: fitz
POSTGRES_PASSWORD: fitz
POSTGRES_DB: fitz_admin
volumes:
- pgdata:/var/lib/postgresql/data
# Schema + seed, run once when the volume is first created.
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fitz -d fitz_admin"]
interval: 5s
timeout: 5s
retries: 10
app:
build:
# Build context is the repo root so the `../..` path dependency
# (the fitz-liveviews library) is available to `fitz build`.
context: ../..
dockerfile: examples/admin/Dockerfile
args:
FITZ_TAG: ${FITZ_TAG:-v0.29.7}
container_name: admin-abm-app
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "postgres://fitz:fitz@db:5432/fitz_admin?sslmode=disable"
JWT_SECRET: "${JWT_SECRET:-demo-admin-secret-cambiame-en-produccion-por-favor}"
ports:
- "3000:3000"
volumes:
pgdata:
Two services, wired to each other:
db is stock postgres:16-alpine. It mounts
db/init.sql
into /docker-entrypoint-initdb.d/, which Postgres runs once, the first time
the data volume is created — that's the schema plus the demo seed (the
admin@fitz.dev login, departments, employees). The healthcheck lets the app
wait until the database is actually accepting connections.
app builds from the Dockerfile (with context: ../.., the repo root, for
the reason above) and depends_on the db being healthy. The two settings that
make the wiring work are in environment:
DATABASE_URLpoints at hostdb— the service name, which Compose resolves over its internal DNS. Inside the network it'sdb:5432, notlocalhost. (Locally, withfitz run, the same var points atlocalhost:5432— see.env.example.)JWT_SECRETis read from your shell (with a demo fallback). In real production you pass a real secret here and never commit it.
The ports: "3000:3000" mapping reaches the app because the binary binds
@server(3000, "0.0.0.0") — more on that in a second.
4. Run it¶
The first run compiles the app (the builder stage — slow once, cached after) and
initializes Postgres from init.sql. When the db is healthy the app starts.
Open http://localhost:3000/, log in with admin@fitz.dev / admin1234,
and it's the same app you've been running with fitz run — sort the grid, edit
an employee through the tabs, flip the language — now in two containers.
To stop: Ctrl-C, then docker compose down. Add -v (docker compose down -v)
to also drop the Postgres volume — do that when you want init.sql to run again
from scratch.
5. The two LiveViews-specific settings¶
A plain HTTP app deploys with the auto-generated files as-is. LiveViews apps have two quirks worth knowing — the Admin ABM already handles both:
a) Bind to 0.0.0.0, not 127.0.0.1. @server(3000) alone binds to
localhost, which is unreachable from outside the container — docker run would
answer "connection refused". The Admin's main.fitz uses
@server(3000, "0.0.0.0"), and EXPOSE 3000 + ports: "3000:3000" follow from
that.
b) Restart on crash. LiveViews hold per-connection state in memory — a
crash drops every open browser session. In production add a restart policy to the
app service so the container comes back:
Both are covered in depth — with the reasoning and the platform-specific variants — in the Deploy reference.
6. Configuration and secrets¶
Everything environment-specific is an env var, never baked into the binary:
| Var | What | Where it's set |
|---|---|---|
DATABASE_URL |
Postgres connection string | docker-compose.yml (host db); .env / shell for fitz run |
JWT_SECRET |
session-token signing key | your shell / secrets manager in prod |
Two design choices worth copying in your own apps:
- The app never runs DDL at boot. The schema lives in
db/init.sql, run by Postgres on first boot; the app just connects per-request. That keepsfitz runand the container identical — neither one migrates on startup. - The seed is data, not config. The demo admin (
admin@fitz.dev) is a row ininit.sql, not an env var. Secrets you rotate (JWT_SECRET) are env vars; fixture data lives in SQL.
7. Getting it onto a real host¶
docker compose up is local. To put the Admin ABM (or your app) on the internet
you add three things this chapter deliberately doesn't re-teach — they're the
same for any LiveViews app and are written up once in the
Deploy reference:
- A container host — Fly.io (
fly launchdetects the Dockerfile), Railway (autodetect), a registry + any box, or Kubernetes (/healthzis auto-mounted for probes). - A reverse proxy that upgrades WebSockets. This is the LiveViews-specific
piece: the
@ws("/live/...")handshake fails if the proxy doesn't forward theUpgradeheaders, and idle sockets die if its timeouts are short. Caddy and Traefik handle it automatically; nginx needs theproxy_http_version 1.1+Upgrade/Connectionsnippet. TLS rides along with the proxy. - Sticky sessions — the one hard limit. Because state lives on the server that
handled the connection, running more than one replica behind a load balancer
needs session affinity, or the
@wsupgrade can land on a replica that has no state. For an MVP, one replica is plenty; this only matters once you scale out. (Sharing state across replicas is a message-bus concern on the roadmap.)
The Deploy reference has the copy-paste config for each.
Checkpoint¶
- [x]
fitz buildproduces a singleadmin-abmbinary. - [x]
docker compose up --buildbrings up the app and its Postgres, and the login works athttp://localhost:3000/exactly likefitz run. - [x] You can explain why
DATABASE_URLpoints atdb(Compose DNS), notlocalhost, inside the network. - [x] You know the two LiveViews-specific settings: bind
0.0.0.0, and a restart policy for the in-memory session state. - [x] You know where secrets and schema live: env vars for secrets,
init.sqlfor schema + seed, no DDL at boot.
Troubleshooting¶
connection refused from the host after docker compose up
The server is bound to 127.0.0.1. Change @server(3000) to
@server(3000, "0.0.0.0") and rebuild (docker compose up --build).
The app container restarts in a loop
Read docker logs admin-abm-app. Usual causes: DATABASE_URL wrong or the
db not ready (should be covered by depends_on: service_healthy), a missing
env var, or a panic at startup. The logs name the exact error.
Login fails / a table doesn't exist
Postgres only runs init.sql when the data volume is first created. If
the volume exists from an earlier, different schema, the new SQL never ran.
Reset it: docker compose down -v then docker compose up --build.
First build is very slow
The builder stage compiles the library + your app from scratch. It's cached after the first run — later builds only recompile what changed.
Works locally, but /live/empleados 404s behind a proxy
The reverse proxy isn't forwarding the WebSocket upgrade. See the proxy snippets in the Deploy reference — or use Caddy/Traefik, which handle it automatically.
You made it — what you can do now¶
Six chapters ago you had fitz --version. Now you can:
- Write a live component from scratch — state, render, events, the
@wsloop (C1). - Wire any interaction with the
data-flv-*conventions and a live<form>, zero JavaScript (C2). - Compose a screen from reusable render helpers with scoped CSS (C3).
- Back it with Postgres and keep multiple users in sync (C4).
- Split reusable, independently-stateful parts into LiveComponents (C5).
- Ship the whole thing as one native binary in a container (C6).
Where to go from here:
- UI catalog — the pattern + snippet for every control, when you need one you haven't built yet.
- Admin ABM — the flagship: every component from the catalog wired into a real back-office over Postgres. The blueprint you just deployed, in full.
- Deploy reference — proxy, TLS, health checks, scaling, and
the systemd (no-Docker) path, for when local
docker composebecomes a real server. - Component gallery — every control on one page, and the seed for the edit-and-preview playground that's coming next.
That's the course. You now understand a Fitz LiveViews app end to end — from one
counter to a deployed, database-backed, internationalized admin panel — with no
bundler, no npm install, and no JavaScript you had to write.