jonnify·codemojex /codemojex/production/the-release

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).

The three stages of the image — select a stage
elixir-base hexpm/elixir · pinned 1.18.4 · OTP 28.5.0.1 build-essential · rustc · git MIX_ENV=prod ① FROM elixir-build deps.get --only prod mix compile · make native mix release codemojex the toolchain stays here ② COPY --from runtime node:22-bookworm-slim the release + inetrc EXPOSE 4000 bin/codemojex start only _build/prod/rel/codemojex crosses — no Elixir toolchain in the final stage
elixir-build → the umbrella-ROOT build: the root 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.
§1

The pinned build

echo/Dockerfile · three stages

Every 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.

echo/Dockerfile · the release stage source
# 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.

Forward — the Node base. The runtime stage bases on 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.

§2

What ships, what stays out

the six COPY apps/*/mix.exs lines

The 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.

Five deps in, one out — select an app
the release · _build/prod/rel/codemojex codemojex the Phoenix surface + the supervision tree echo_mq the bus echo_data identity echo_wire the wire echo_store the cache echo_bot telegram the runtime stage installs no Erlang — the release carries its own excluded echo_graft stays out
echo_bot → BUILT — not excluded. Telegram I/O is a real dependency: 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.

§3

The machine

echo/fly.toml

app = "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.

echo/fly.toml · the deploy + the service floor source
[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.

§4

Boot, not build

echo/config/runtime.exs

runtime.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.

echo/config/runtime.exs · the IPv6 floor source
# 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.

§5

References

sources · related

Sources

Related