Codemojex · C8.1 · a dive of Production Deployment
The whole engine, one release.
mix release codemojex assembles one self-contained OTP release — the Phoenix surface, the bus consumers, the cache tables, the Telegram bridge, everything the supervision tree holds — from the umbrella root, inside a pinned three-stage image. The artifact becomes one Fly machine: a rolling deploy replaces machines one at a time, and the service keeps at least one running so a live /socket stays up under a player.
Grounding — echo/Dockerfile (the three-stage release image) · echo/fly.toml (the machine + the deploy) · echo/config/runtime.exs (boot-time config) · codemojex/echo_bot.ex (Codemojex.EchoBot.deliver/3 · ingest/1).
mix.exs mix.lock + config, the six apps’ mix.exs, then mix deps.get --only prod → mix deps.compile → the sources → RUN mix compile → RUN make -C apps/echo_data/native → RUN mix release codemojex. No mix phx.digest: the Mini App is JSON + a /socket channel, no Plug.Static — nothing to digest.The pinned build
echo/Dockerfile · three stagesEvery version in the image is an ARG — ELIXIR_VERSION=1.18.4, OTP_VERSION=28.5.0.1, DEBIAN_DATE=20260518, NODE_VERSION=22 — and fly.toml’s [build.args] pins the same four values. The tag is the contract: hexpm/elixir:1.18.4-erlang-28.5.0.1-debian-bookworm-20260518, never a floating latest. What builds in the image is what the machine runs.
# Compile from the umbrella ROOT (NOT `cd apps/codemojex`). No `mix phx.digest` step: the codemojex # endpoint serves no static asset bundle (the Mini App is JSON + a `/socket` channel, with no Plug.Static), # so there is nothing to digest. RUN mix compile # Build echo_data's native codec BEFORE assembling the release so `mix release` packs it. […] RUN make -C apps/echo_data/native RUN mix release codemojex # ============================================================================= # Stage: Runtime (slim Debian + Node; the release + its shared-object deps + node) # ============================================================================= FROM node:${NODE_VERSION}-bookworm-slim AS runtime # […] COPY --from=elixir-build /app/_build/prod/rel/codemojex ./ COPY infra/inetrc /app/inetrc EXPOSE 4000 ENTRYPOINT ["bin/codemojex", "start"]
The build runs from the umbrella ROOT, never cd apps/codemojex — mix.lock and the shared config live at the root, and the six copied mix.exs files give dependency resolution the umbrella shape before any source is copied. The layer order is the cache order: manifests, then deps, then sources, so a source edit re-runs mix compile without re-fetching a dependency.
The make line is the step mix compile never triggers: echo_data’s native codec is built from its native/Makefile, not a mix/hex dep. It compiles branded_id.rs → apps/echo_data/priv/libbranded_rs.so (rustc — Debian’s crate-less compiler; the Rust core is no_std with no external crates, so no Cargo and no network) and links echo_native.c → apps/echo_data/priv/echo_native.so (gcc, against the ERTS headers, rpath $ORIGIN so the NIF finds the Rust cdylib beside it). Both .so land in priv/, which the release packs; the Dockerfile’s own note pins the posture — EchoData.Native loads echo_native at boot and BrandedId.self_check!/0 asserts the native + pure-Elixir paths agree. The codec stays optional: a missing .so falls back to pure Elixir.
The runtime stage installs no Erlang and no Elixir — the release carries its own runtime, and only openssl, libstdc++6, libncurses6, locales, and ca-certificates are added over the slim base. One more file crosses: infra/inetrc, the Erlang resolver config the machine points ERL_INETRC at.
node:22-bookworm-slim — the same Debian Bookworm userland and glibc as debian:bookworm-slim, so the OTP release stays binary-compatible — and node is reserved for a Node worker that runs beside the BEAM release. That worker is forward surface, not this image’s: the build is Elixir-only, with no npm and no vite step, and nothing JS is built or fetched.The release name is codemojex — the OTP artifact mix release codemojex assembles into _build/prod/rel/codemojex and ENTRYPOINT ["bin/codemojex", "start"] boots. No release migrate task is defined yet; the Ecto migrations ride inside apps/codemojex/priv, ready for a bin/codemojex eval step.
What ships, what stays out
the six COPY apps/*/mix.exs linesThe release is the whole engine because the build copies the whole engine’s shape: six mix.exs lines name the apps — codemojex over echo_mq · echo_data · echo_wire · echo_store · echo_bot. Only echo_graft stays out. Each dep’s footprint is exactly the COPY lines it needs: lib-only for the wire, the bus, and the store; native/ added for echo_data; vendor/ + priv/ added for echo_bot.
Codemojex.EchoBot.deliver/3 sends a notification out; Codemojex.EchoBot.ingest/1 bridges a webhook update onto the bus. It ships its vendor/ (the vendored ex_gram on its elixirc_paths) and priv/ (the bot YAML). Footprint: COPY apps/echo_bot/lib · COPY apps/echo_bot/vendor · COPY apps/echo_bot/priv.The Dockerfile header draws the line itself: “only echo_graft is EXCLUDED (its own concern — echo_bot IS built: codemojex calls EchoBot.deliver/ingest for Telegram I/O)”. On disk those calls resolve through alias Codemojex.{Bus, EchoBot, RateLimiter} to Codemojex.EchoBot — the codemojex-side bridge in codemojex/echo_bot.ex over the echo_bot engine.
The machine
echo/fly.tomlapp = "codemojex", primary_region = "fra", one VM shape: shared · 2 cpus · 1024 MB. The deploy strategy is rolling — machines are replaced one at a time — with auto_rollback = true returning to the prior release when a deploy fails its checks. The service floor is the live-game rule: at least one machine always up.
[deploy] strategy = "rolling" [experimental] auto_rollback = true [[vm]] cpu_kind = "shared" cpus = 2 memory_mb = 1024 [[services]] protocol = "tcp" internal_port = 4000 # Keep at least one machine always running so live game `/socket` websocket sessions # are not dropped; auto_stop is OFF so a node is not reaped mid-session. auto_stop_machines = false auto_start_machines = true min_machines_running = 1 # […] # HTTP liveness check — GET /api/health [[services.http_checks]] interval = "30s" timeout = "5s" grace_period = "30s" method = "get" path = "/api/health"
The floor exists for the socket: a game in play holds a live /socket websocket, so auto_stop_machines = false and min_machines_running = 1 keep a node under it — the comment in the file says why: live sessions are not dropped, a node is not reaped mid-session. Concurrency is counted in connections, hard 250 / soft 200; port 80 redirects with force_https; port 443 terminates TLS with alpn = ["h2", "http/1.1"].
The liveness check is GET /api/health every 30 seconds — the same open route the C7 router drew, on the :api pipeline with no :auth, so a health probe never touches identity. A stopping machine gets kill_signal = "SIGTERM" and kill_timeout = "30s" — 30 seconds between the signal and the kill, the window a rolling replacement rides.
The file’s first comment is the operational rule: “The Operator creates the app + machines and pushes to deploy — never fly deploy locally.” The repo produces the release; the deploy is the Operator’s.
Boot, not build
echo/config/runtime.exsruntime.exs is evaluated at boot, in the running machine — the release artifact carries no secrets. DATABASE_URL and SECRET_KEY_BASE raise when missing, so a misconfigured machine fails at boot, not mid-request; everything else reads the [env] block fly.toml set.
# Fly's 6PN is IPv6-only (echo-postgres.internal resolves to an AAAA record only), so Postgrex
# must dial inet6 — otherwise the Repo silently never connects (Ecto retries forever, no crash,
# and a DB-less /api/health hides it). ECTO_IPV6=true is already set in fly.toml; read it here.
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :codemojex, Codemojex.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
The IPv6 floor runs through three settings that land together. ECTO_IPV6 = "true" makes Postgrex dial :inet6 — without it the Repo silently never connects, Ecto retries forever with no crash, and a DB-less /api/health hides the failure behind a green check. ERL_INETRC = "/app/inetrc" points Erlang’s resolver at the Fly DNS file the runtime stage copied. ERL_AFLAGS = "-proto_dist inet6_tcp" runs BEAM distribution over inet6 — harmless single-node, ready if a release ever clusters.
The endpoint binds http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: PORT] on PORT = "4000", and PHX_HOST (codemoji.games; default codemojex.fly.dev) drives URL generation. The socket-origin line is the cutover’s safety: check_origin: ["https://#{phx_host}", "https://codemojex.fly.dev"] keeps BOTH hosts valid for /socket, so the apex flip drops no live game socket on either host. The shared Valkey wires from VALKEY_HOST / VALKEY_PASSWORD / VALKEY_PORT; unset, the connectors fall back to 127.0.0.1:6390 with no auth — local dev boots the same release path.
The split is the chapter’s law in miniature: the image pins what never changes per machine (the toolchain, the code, the .so), and boot reads what does (the database, the secret, the host, the Valkey). A Fly app rename or a custom apex is a config change, not a code edit.
References
sources · relatedSources
- Fly — Machinesthe machine the release becomes.
- Fly — Machine sizingthe shared-2cpu / 1024 MB shape in
[[vm]]. - Fly — Private networkingthe IPv6-only 6PN behind
ECTO_IPV6and the inetrc. - Erlang/OTP — supervisorthe tree
bin/codemojex startboots and supervises.
Related
- C8 — Production Deploymentthe chapter this dive opens
- EchoMQ — the coursethe bus that ships inside this same release