This is the deployment we actually run, in the order we run it: cross-compile the bundle on your dev machine, package it into a minimal image, bootstrap the database on the app machine, and confirm it's serving. Written so you can follow it for a different app.
It assumes you already know what zonai build produces and what --release means — see
Building for Production, Cross-Compilation,
and Running the Server first.
Written against zonai 0.6.2. Project-linked builds (in-process ops/rules) landed in 0.6.1.
No released version cross-compiles the .aot snapshots correctly yet. Through v0.6.2 inclusive,
zonai build passes buildSettings to the .exe compile and not to the aot-snapshot one, so a
cross-compiled bundle ships two host-arch .aot files inside an otherwise correct linux/x64 image.
It is fixed on main but not in any tag, and lib/gen/version.dart still reads 0.6.2 there — so
zonai version prints the same string either way and cannot tell you which one you have. Until
the next release, assume you have the bug and keep the arch check in
step 3; it catches this exact case.
Do You Even Need Docker?#
You need a Dockerfile — Fly builds an image — but you do not need Docker installed locally, and
nothing gets compiled inside the image. zonai build cross-compiles a Linux bundle directly on your
Mac, and the Dockerfile just copies it in. Fly's remote builder assembles the image.
The one case that needs a real Linux container is rebuilding the zonai CLI itself from source —
see Rebuilding the framework from source
at the
bottom. If your installed zonai already builds and serves your app, skip it.
What You End Up With#
One Fly machine, with a persistent volume for the SQLite database. If your app has a second, public-facing Dart process (a proxy, a webhook receiver, a Jaspr front end), it looks like this:
┌─────────────────────────────────────────┐
internet ──443──▶│ public process :8080 │
│ proxy + webhooks │
│ │ │
│ └─▶ zonai serve 127.0.0.1:8081 │
│ │ │
│ ▼ │
│ /app/.zonai/data ◀── Fly volume │
└─────────────────────────────────────────┘
If zonai is the only process, it's simpler: bind zonai serve to 0.0.0.0 on the port
fly.toml
exposes, and skip the second binary and the sequencing in step 7. Each step notes
where that applies.
Two properties to be aware of before you start:
-
One machine, not more. Zonai stores everything in a SQLite file. Multiple
zonai serveinstances sharing one database file isn't supported, so this is a single-node deployment by construction. - You need a volume. Without one, every deploy and every restart starts from an empty database.
Prerequisites#
| Requirement | Notes |
|---|---|
zonai binary |
in your app directory (e.g. apps/server/zonai) |
| Dart SDK | on the build machine |
flyctl | installed and authenticated (fly auth login) |
| Docker | not needed locally — Fly's remote builder handles it |
You do not need a container or an emulator to produce Linux binaries. Zonai cross-compiles directly,
so an Apple Silicon Mac can build a linux/x64 bundle natively.
1. Tell Zonai What to Build For#
In zonai.yaml, next to your existing paths:
version: 0.6.2
migrationsPath: .zonai/migrations
schemasPath: lib/src/schemas
rulesPath: lib/src/rules
operationsPath: lib/src/operations
extensionsPath: lib/src/extensions
configPath: lib/src/config
# Fly machines run linux/x64.
buildSettings:
targetOs: linux
targetArch: x64
buildSettings is what turns zonai build into a cross-compile — see
Cross-Compilation. Keep version
matching the binary you're
actually running.
2. Put Compile-Time Config in .env#
Zonai reads a .env from your app root — the same directory as zonai.yaml
— and bakes every
key into the compiled workers as a -D define. Your config reads them back with
String.fromEnvironment:
// lib/src/config/db_config.dart
AppConfig main() => AppConfig(
appName: 'MyApp',
passwordSecret: const String.fromEnvironment('PASSWORD_SECRET', defaultValue: 'dev-password-secret'),
jwtSecret: const String.fromEnvironment('JWT_SECRET', defaultValue: 'dev-jwt-secret'),
baseUrl: const String.fromEnvironment('BASE_URL', defaultValue: 'http://localhost:8080'),
);
# apps/server/.env — gitignored, never committed
JWT_SECRET=...
PASSWORD_SECRET=...
BASE_URL=https://your-app.fly.dev
Three things worth knowing:
-
No
--flavormeans plain.env. With--flavor prod, zonai reads.env.prodonly — it does not fall back to.env. If.env.prodis missing you get a warning (No flavor-specific .env file found for flavor: prod) and no defines at all, so everyString.fromEnvironmentsilently takes itsdefaultValue. - Defaults are silent. A missing key is never an error. Verify after building — see step 3.
-
The compiled artifacts contain your secrets. They're baked in, not read at runtime. Treat
build/as sensitive and don't publish the image anywhere public. See Environment & Secrets.
You can also pass keys on the command line instead of (or in addition to) .env, with
--dart-define KEY=VALUE. Note the space — --dart-define=KEY=VALUE
fails to parse, because
argument splitting happens on every =. CLI defines win over matching
.env keys.
There is no --dart-define-from-file: the env file is found by name, never passed as a flag, and
an unrecognized flag is ignored without a warning — so a build that names one succeeds with no
defines at all. See
Environment Variables.
Compile-Time vs Runtime Config#
Two different mechanisms, easy to conflate:
| Kind | Where it's set | Where it's read | Example |
|---|---|---|---|
| Compile-time | .env at build |
String.fromEnvironment |
JWT_SECRET |
| Runtime | fly secrets set |
Platform.environment |
webhook secrets |
Anything your own Dart process reads at startup belongs in Fly secrets (step 9).
Anything zonai's config worker needs belongs in .env at build time.
3. Build the Deploy Bundle#
One script produces everything the image needs. Nothing is compiled inside Docker.
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
echo "==> Building apps/server (zonai build --release)"
(cd apps/server && ./zonai build --release)
# Only if you have a second, public-facing process:
mkdir -p apps/gateway/build
(cd apps/gateway && dart pub get && dart compile exe \
--target-os linux --target-arch x64 \
bin/gateway.dart -o build/gateway)
# Nothing host-arch may reach the image. A wrong-arch artifact builds and
# deploys without complaint and only fails at runtime, so check here.
bad=0
while IFS= read -r artifact; do
desc="$(file -b "$artifact")"
case "$desc" in
*ELF*|*Mach-O*|*PE32*)
if [[ "$desc" != *x86-64* ]]; then
echo "ERROR: $artifact is not x86-64: $desc" >&2
bad=1
fi
;;
esac
done < <(find apps/server/build apps/gateway/build -type f)
[[ "$bad" -eq 0 ]] || { echo "Bundle has non-linux/x64 binaries." >&2; exit 1; }
You should get apps/server/build/ containing zonai, .zonai/executables/,
.zonai/lib/,
.zonai/migrations/, plus your second binary if you have one.
Verify before deploying. Two checks worth running the first time:
# Everything linux/x64?
find apps/server/build -type f -exec file {} \; | grep -v "x86-64" | grep -iE "ELF|Mach-O"
# (no output = good)
# Did your .env actually get baked in?
strings apps/server/build/.zonai/executables/db_config.exe | grep -c 'dev-jwt-secret'
# 0 = your real secret was used; 1 = it fell back to the default
Every object file in the bundle should be x86-64, including the two .aot snapshots in
.zonai/executables/. On every released version through v0.6.2 they come out for the build host
— zonai build passes buildSettings to the .exe
compile and not to the aot-snapshot one — and a
bundle carrying them deploys and serves without complaint, so keep this check in your script rather
than trusting the build.
If your bundle has host-arch .aot files, recompile them from the generated sources with the target
flags and the same -D defines, after zonai build:
for worker in rules operations; do
dart compile aot-snapshot --target-os linux --target-arch x64 \
"apps/server/.dart_tool/zonai/db_${worker}.dart" \
-o "apps/server/build/.zonai/executables/db_${worker}.aot"
done
The -D defines matter: these snapshots are a second compile of the same sources as the .exe, so
they need the same .env-derived defines or the in-process path reads different config than the
worker path. The arch check above then passes for the right reason.
4. Dockerfile#
The image compiles nothing. It installs the runtime libraries and copies the prebuilt bundle in.
FROM debian:bookworm-slim
# libsqlite3-0 because the compiled workers link against libsqlite3.
# Debian ships only the versioned libsqlite3.so.0, not the unversioned
# libsqlite3.so that FFI loaders look for — hence the symlink. That symlink
# otherwise only comes from the much heavier -dev package.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/lib/x86_64-linux-gnu/libsqlite3.so.0 \
/usr/lib/x86_64-linux-gnu/libsqlite3.so
WORKDIR /app
COPY apps/server/build/ .
COPY apps/gateway/build/gateway ./gateway
COPY apps/server/scripts/ ./scripts/
RUN chmod +x ./zonai ./gateway ./scripts/start.sh ./scripts/ensure_admin.sh
EXPOSE 8080
CMD ["./scripts/start.sh"]
curl is there because start.sh uses it to wait for zonai's health endpoint. Drop it if you don't.
If zonai is your only process, you don't need the scripts at all — CMD ["./zonai", "serve", "--release", "--host", "0.0.0.0"]
plus a release-safe bootstrap is enough (but read step 6
before reaching for
release_command).
5. .dockerignore#
The build context only needs the build outputs and the scripts. Without this, Fly spends minutes
tarring and uploading your entire repo — including every .git.
*
!Dockerfile
!apps/
!apps/server/
!apps/server/build/
!apps/server/build/**
!apps/server/scripts/
!apps/server/scripts/**
!apps/gateway/
!apps/gateway/build/
!apps/gateway/build/gateway
Note the pattern: deny everything, then re-include. Every ancestor directory of a kept path needs
its own ! line — once a parent is excluded, a deeper negation alone won't reliably pull it back in.
6. Bootstrap Script#
Migrations and any first-run seeding go in their own script. This one also creates the admin account a gateway process signs in as; skip that half if you don't need one.
#!/bin/sh
set -eu
./zonai db migrate apply
if ./zonai db admin list | grep -Fq "email: ${ADMIN_EMAIL}"; then
echo "Admin account for ${ADMIN_EMAIL} already exists, skipping."
else
# --no-verify: no SMTP configured, so the verification email has nowhere to go
./zonai db admin add -e "${ADMIN_EMAIL}" -p "${ADMIN_PASSWORD}" --no-verify
fi
Four things this ordering depends on:
-
Migrations first.
zonai serveauto-applies pending migrations, but that's too late here:db admintouches a table a migration creates, so on a fresh volume it fails unless migrations have already been applied. -
Check-then-add, not add.
db admin adderrors on a duplicate email and returns a non-zero exit code, which underset -ekills the boot. Checking first makes the script safe to run on every boot. -
Match the
listoutput loosely.db admin listindents each field (email: you@example.com), sogrep -Fqx "email: ..."never matches and you'd re-add on every boot. Plaingrep -Fqon the substring is what works. -
No server needed. Both commands work against the database file directly, which is why this can
run before
zonai servestarts.
Run this from your start script, not a Fly release_command. Release-command machines get the
network, env and secrets but no volumes — per Fly's config reference: "It has no volumes
attached, and the Machine is destroyed after the command completes." Anything written to your data
directory from one is discarded, silently, after reporting success. Since both commands above write
to the data directory, they have to run on the app machine.
7. Start Script#
Sequences everything on boot. This is the image's CMD.
#!/usr/bin/env bash
set -euo pipefail
ZONAI_PORT="${ZONAI_INTERNAL_PORT:-8081}"
# Runs here, on the app machine, because this is where the volume is mounted.
./scripts/ensure_admin.sh
./zonai serve --release --host 127.0.0.1 --port "$ZONAI_PORT" &
ZONAI_PID=$!
GATEWAY_PID=""
shutdown() {
kill -TERM "$ZONAI_PID" 2>/dev/null || true
[ -n "$GATEWAY_PID" ] && kill -TERM "$GATEWAY_PID" 2>/dev/null || true
}
trap shutdown TERM INT
# Wait for zonai to actually answer before starting the public process,
# which signs in at startup and would fail against a half-booted server.
# Bounded, and gives up if the zonai process itself is gone.
boot_deadline=$(( SECONDS + 120 ))
until curl -fs "http://127.0.0.1:${ZONAI_PORT}/health" >/dev/null 2>&1; do
if ! kill -0 "$ZONAI_PID" 2>/dev/null; then
echo "zonai exited before it became healthy; exiting so Fly restarts" >&2
exit 1
fi
if [ "$SECONDS" -ge "$boot_deadline" ]; then
echo "zonai did not become healthy within 120s; exiting so Fly restarts" >&2
shutdown
exit 1
fi
sleep 0.5
done
export ZONAI_BASE_URL="http://127.0.0.1:${ZONAI_PORT}"
./gateway &
GATEWAY_PID=$!
# Supervise both. Whichever dies first takes the machine down with it.
status=0
wait -n || status=$?
echo "a supervised process exited (status ${status}); shutting down so Fly restarts" >&2
shutdown
exit 1
Points that matter:
-
--releaseonzonai servedisables file watching and recompiling. Always use it in production;build --releasemust have run first. -
zonai servebinds to 127.0.0.1 so it is never publicly reachable. The front-end process is the only thing on a public port. See Server Binding. -
The health poll hits zonai's own built-in
GET /health. Your public process's health path is whatever you gave it — the two are different endpoints on different ports. -
Neither process is
exec'd. The script stays alive as their parent so it can notice one dying. -
wait -nneeds bash, which is why the shebang isbashand notsh; the Debian image from step 4 has bash 5.
Don't exec the front-end process. exec ./gateway is the tempting last line — it's the usual
advice for signal delivery — but it replaces the shell, destroying the only process that knew
zonai serve existed. The gateway inherits zonai as a child and never wait()s on it, so if zonai
dies it becomes an unreaped zombie while the gateway keeps serving its public port perfectly
happily and every proxied request fails against a backend that is no longer there. Nothing exits,
nothing logs, and Fly never restarts the machine. This is not hypothetical — it produced a
multi-hour production outage that reported itself healthy the entire time.
The signal argument doesn't hold on Fly anyway: PID 1 in a Fly machine is Fly's own init, so your
script is already a direct child of it and receives SIGTERM normally. The trap above forwards it
to both children.
The bounded boot loop matters for the same reason. An unbounded until curl means a zonai that dies
during startup — an OOM mid-migration, a bad .env, a corrupt volume — leaves the loop spinning
forever with the front-end process never started at all. Same silent hang, reached a different way,
and worse: nothing is listening on the public port to even return a 500.
If you have no front-end process: drop the poll and run zonai in the foreground bound to the
public port — exec ./zonai serve --release --host 0.0.0.0 --port 8080.
8. fly.toml#
app = "myapp-server"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
# No [deploy] release_command — see step 6.
[env]
ZONAI_INTERNAL_PORT = "8081"
# Required. The SQLite database lives here; without a volume every deploy
# and restart starts from an empty database.
[[mounts]]
source = "myapp_data"
destination = "/app/.zonai/data"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
grace_period = "15s"
interval = "15s"
method = "GET"
path = "/healthz"
timeout = "5s"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
internal_port must match whatever your public process listens on, and the check's path
must be an
endpoint that process serves (/healthz here). If zonai is the public process, use its built-in
/health instead.
A Front-End Health Check Must Probe Zonai#
If your public process is a front end for zonai, its health endpoint has to actually probe zonai —
GET /health on the internal port — and return non-2xx when that fails. The obvious handler:
// in your gateway, not zonai — shelf shown here
if (request.url.path == 'healthz') {
return Response.ok('ok'); // reports only that *this* process is alive
}
satisfies the sentence above exactly and is a health check that cannot fail. It reports on the
liveness of the process answering the question, which is never the process at risk. Paired with an
unsupervised zonai serve (step 7), Fly's check passes indefinitely on a machine
whose entire reason for existing is dead.
Fail closed instead:
Future<Response> _healthz(String zonaiBaseUrl) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 3);
try {
final request = await client.getUrl(Uri.parse('$zonaiBaseUrl/health'));
final response = await request.close().timeout(const Duration(seconds: 3));
await response.drain<void>();
if (response.statusCode >= 200 && response.statusCode < 300) {
return Response.ok('ok');
}
return Response(503, body: 'zonai unhealthy: HTTP ${response.statusCode}');
} on Object catch (error) {
return Response(503, body: 'zonai unreachable: $error');
} finally {
client.close(force: true);
}
}
Keep your timeout under the [[http_service.checks]] timeout (5s above) so you answer 503 yourself
rather than having the edge time the check out with no detail about which component is down.
Worth testing against a real loopback HttpServer and a genuinely closed port — a stubbed client
won't reproduce connection-refused, which is the case that actually matters.
Sizing the Machine#
The [[vm]] block is not optional boilerplate. Omit it and Fly gives you its 256MB default,
which leaves about 207MB usable after the kernel's share — under which the kernel OOM-kills
zonai serve during ordinary work. Measured on a single-machine deployment with a small front-end
process:
zonai serve RSS, idle | 69 MB |
zonai serve peak RSS (VmHWM), light traffic | 88 MB |
zonai serve anon-RSS at the moment of an OOM kill | 121 MB |
| a small Dart front-end process, peak RSS | 12 MB |
Treat 512mb as a floor, not a default to shrink from — idle is only ~69MB, but the jump to
121MB came from one admin creating a user. Workers are separate executables invoked per request, so
per-request memory is real and additive; sizing from an idle measurement under-provisions. 1GB is a
comfortable place to sit if you're not counting pennies.
One debugging note: zonai serve reserves ~620MB of virtual address space at idle, so that is
the total-vm: figure in any OOM message. It is harmless and it is not the number that matters —
read anon-rss: instead, or you'll chase the wrong thing.
The volume destination must match zonai's data directory — /app/.zonai/data
when WORKDIR is
/app, since the database lives at .zonai/data relative to the app directory.
auto_stop_machines = false with min_machines_running = 1 keeps the single machine up. If you'd
rather scale to zero, be aware every cold start re-runs the bootstrap script.
9. Create the App, Volume, and Secrets#
The volume must exist before the first deploy:
fly apps create myapp-server
fly volumes create myapp_data --app myapp-server --region iad --size 1
fly secrets set --app myapp-server \
ADMIN_EMAIL="admin@example.com" \
ADMIN_PASSWORD="<generate a strong one>" \
STRIPE_WEBHOOK_SECRET="<from your provider's dashboard>"
The volume's --region must match primary_region, and its name must match [[mounts]] source.
Size is in GB.
These secrets are the runtime ones from step 2's table
— read
by your own process via Platform.environment. Zonai's own config secrets went in at build time via
.env.
10. Deploy and Verify#
./scripts/build_deploy_bundle.sh # rebuild the artifacts
fly deploy
fly deploy with no flags uses Fly's remote builder, which is what you want: the bundle is already
linux/x64 and the image must be too. Avoid --local-only on Apple Silicon — it builds an
arm64
image around x64 binaries, which only fails once the machine boots. If you do want to build
locally, build the image explicitly with --platform linux/amd64 first (see
Verifying locally).
Then confirm it's actually running:
fly status
PROCESS │ ID │ VERSION │ REGION │ STATE │ CHECKS
app │ 8e2579c7724398 │ 3 │ iad │ started │ 1 total, 1 passing
Check for started, not just a successful deploy. If the machine previously exhausted its
restart budget, Fly updates the config, reports the deploy as successful, and leaves the machine
stopped. Start it explicitly with fly machine start <machine-id>.
Boot logs should look like this on a fresh volume:
Applied pending SQL migrations
Admin created successfully
Serving at http://127.0.0.1:8081/
gateway listening on :8080, proxying to http://127.0.0.1:8081
Health check 'servicecheck-00-http-8080' on port 8080 is now passing.
On later boots the second line becomes Admin account for … already exists, skipping. Finally, from
outside:
curl -s -o /dev/null -w "%{http_code}\n" https://myapp-server.fly.dev/healthz
# 200
And, once, to see which dispatch the deployed binary got:
fly ssh console -C '/app/zonai version'
# Zonai: v0.6.2
# Ops/rules: in-process (project-linked)
The other answer is worker IPC, where ops and rules run as separate processes instead. Both serve
identically and nothing in the logs distinguishes them, which is exactly why it's worth asking once.
Redeploying#
The whole loop, every time:
./scripts/build_deploy_bundle.sh
fly deploy
fly status
The build script is not optional — fly deploy only packages whatever is currently in build/.
Skipping it silently ships your previous binaries.
Verifying Locally (Optional)#
If you'd rather not discover problems on Fly, the same image runs locally. On an ARM machine you need the platform flag:
docker build --platform linux/amd64 -t myapp:verify .
docker volume create myapp-data
docker run -d --name myapp --platform linux/amd64 \
-e ADMIN_EMAIL=verify@example.com \
-e ADMIN_PASSWORD='verify-pw' \
-e STRIPE_WEBHOOK_SECRET='test-secret' \
-v myapp-data:/app/.zonai/data \
-p 8099:8080 myapp:verify
docker logs myapp
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8099/healthz
Restarting the container against the same volume is a good check that your bootstrap script is genuinely idempotent:
docker restart myapp && docker logs myapp | tail -5
Clean up with docker rm -f myapp && docker volume rm myapp-data.
Don't Ship a Hardcoded localhost baseUrl#
AppConfig.baseUrl is easy to leave hardcoded to http://localhost:8080 during local development
and forget about — it compiles fine, serve never complains, and nothing breaks loudly. It just
means every absolute link the server generates (email templates, magic links, anything built from
baseUrl) silently points at localhost in production.
Read it from .env at build time, as in step 2:
baseUrl: const String.fromEnvironment('BASE_URL', defaultValue: 'http://localhost:8080'),
baseUrl isn't a secret, so a runtime Platform.environment['BASE_URL'] plus a
fly.toml [env]
entry works too, and has the advantage of not needing a rebuild to change. Pick one — the failure
mode to avoid is a literal in the source.
Nothing checks this for you at build or boot time, so it's worth grepping your own AppConfig
for
hardcoded localhost before every first production deploy, not just this one field.
If the Server Segfaults on Boot#
If a compiled zonai serve crashes with a segfault within a second of starting on Linux, you are
running a release older than v0.3.5. That version fixed an ABI mismatch that opened one database
file through two separately built SQLite libraries (3404812).
Upgrade with ./zonai version update.
Appendix: Rebuilding the Framework From Source#
Everything above cross-compiles from your dev machine. This appendix is for a narrower case:
producing the zonai CLI binary itself from source — needed only if you're working on the zonai
framework, or you need a fix that isn't published as a release yet.
Two limitations a normal consumer deploy never hits:
-
zonai build's own cross-compile support only produceslinux/x64. There is no cross-compile path tolinux/arm64today, and a nativezonai buildon alinux/arm64machine fails outright withUnsupported operation: Unsupported architecture: linux_arm64. -
Producing new prebuilt native libraries (Argon2/libsodium, resqlite's
sqlite3mcbuild) requires compiling them natively on each target OS. A consumer app'szonai buildnever does this — it links against the prebuilt libraries the framework ships.
The workable recipe, matching zonai's own CI (.github/workflows/compile.yml and the zonai.compile
script in scripts.yaml), is to build inside a genuine Linux container regardless of your host:
docker run --rm -it --platform linux/amd64 -v "$PWD":/work -w /work dart:stable bash
Inside the container:
apt-get update
apt-get install -y build-essential autoconf automake libtool m4 pkg-config cmake clang lld git ca-certificates
build-essential alone is not enough — the autoconf/automake/libtool/m4
quartet is
required for the vendored libsodium/Argon2 build. Confirmed against a bare dart:stable
container:
the build hook fails partway through without them.
If the repo has private submodules that need a GitHub token to fetch:
git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
dart pub get
Path A: Compile the CLI#
Install sip and run the framework's compile pipeline:
dart pub global activate --source git https://github.com/mrgnhnt96/sip.git --git-ref main
dart pub global run sip_cli:sip run zonai compile
This runs version.gen, server.copy-to-cli, resqlite.gen, argon2.gen,
web.gen, then the
final dart compile exe — roughly 24 minutes from a cold cache.
Compiling manually instead of via sip requires passing the compiled marker explicitly:
dart compile exe -D__ZONAI_COMPILED__=true bin/zonai.dart -o zonai
Without it, zonai build's "copy the currently-running compiled binary" fast path never triggers and
it falls back to downloading a GitHub release — which fails for a private repo or an unpublished
version.
Path B: Use a Release Binary#
Prebuilt binaries ship as GitHub release assets (zonai-linux-x64.zip, zonai-linux-arm64.zip, …)
as of v0.3.4. Much faster than a from-scratch compile:
gh release list --repo mrgnhnt96/zonai
gh api repos/mrgnhnt96/zonai/releases/tags/vX.Y.Z
Because the repo is private, the auth header goes on the asset download URL, not just the release-metadata call:
curl -L \
-H "Authorization: token $GH_TOKEN" \
-H "Accept: application/octet-stream" \
"<asset_url>" \
-o zonai.zip
unzip zonai.zip
Verify you got the architecture you expect (file zonai) before trusting it.
Then Build the App Bundle#
Drop the Linux binary into your app directory, replacing the gitignored dev binary, and build as
normal — still inside the same linux/amd64 container:
cp zonai path/to/your-app/server/zonai
cd path/to/your-app/server
./zonai build --release
From there, rejoin the main path at step 4.