Deployment
How Heimdall is built, packaged into container images, and pushed for release. This page is primarily for maintainers and self-hosters — it documents the build toolchain rather than runtime configuration.
The Rust API (and the Discord/Twitch/YouTube bots) are built with Bazel 9.1
backed by a BuildBuddy remote cache, and packaged as OCI images directly from
Bazel using rules_img. The Next.js apps (Backend, ID, Policies) and the Docusaurus
docs site are built with their own multi-stage Docker builds.
Overview
| Component | Build tool | Image source |
|---|---|---|
API (Rust, //platform/api) | Bazel + BuildBuddy | rules_img image, base @runtime_base |
| Discord / Twitch / YouTube bots (Rust) | Bazel + BuildBuddy | rules_img image, base @runtime_base |
| Backend (Next.js) | Docker | docker/backend.Dockerfile |
| ID (Next.js) | Docker | docker/id.Dockerfile |
| Policies (Next.js) | Docker | docker/policies.Dockerfile |
| Docs (Docusaurus) | pnpm build | Cloudflare Pages static deploy — see Docs site |
Bazel build
Bazel version is pinned in .bazelversion (9.1.0). Build flags live in
.bazelrc (clang/lld linker, system OpenSSL via OPENSSL_NO_VENDOR=1, shared
repository cache). CI configuration is imported by the setup-bazel GitHub action
rather than .bazelrc to avoid double-importing.
Common commands
# Build all Rust crates and apps (excludes container image targets)
bazel build //crates/... //platform/... --build_tag_filters=-manual
# Run all crate tests
bazel test //crates/... --build_tag_filters=-manual
# Clippy via aspect
bazel build //crates/... //platform/... \
--aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect \
--output_groups=clippy_checks
# Regenerate vendored crate dependencies
bazel run //vendor:cargo_vendor
Container image targets are tagged manual, so --build_tag_filters=-manual
excludes them from wildcard builds.
justfile shortcuts
The justfile wraps the most common Bazel invocations:
| Recipe | Runs |
|---|---|
just bazel-build | bazel build //crates/... //platform/... --build_tag_filters=-manual |
just bazel-test | bazel test //crates/... --build_tag_filters=-manual |
just bazel-clippy | Clippy aspect build (see above) |
just bazel-vendor | bazel run //vendor:cargo_vendor |
just bazel-schemas | Regenerates openapi.json + schema.graphql via //platform/api:generate-openapi and //platform/api:generate-schema |
Container images
Rust API (and bots) via rules_img
The API image is defined entirely in platform/api/BUILD.bazel. The rust_binary
target :api is layered onto the shared runtime base and assembled into an OCI
manifest:
image_layer(:api_layer) places theheimdall-apibinary at/usr/local/bin/heimdall-apiand copies thedefault.toml,production.toml, andstaging.tomlconfig files into/app/config/.image_manifest(:api_image) usesbase = "@runtime_base", entrypoint["/sbin/tini", "--", "/usr/local/bin/heimdall-api"], runs as user1001, and sets defaultsTZ=Europe/Berlin,RUST_LOG=info,HEIMDALL__SERVER__HOST=0.0.0.0,HEIMDALL__SERVER__PORT=3000.
The @runtime_base image is pulled in misc/toolchains/docker.MODULE.bazel from
ghcr.io/smutjebot/heimdall/runtime-base (rules_img pull rule). It is built
from docker/runtime-base.Dockerfile (debian:trixie-slim +
ca-certificates, openssl, tini, curl, tzdata, and a heimdall
user/group at UID/GID 1001).
The three bots follow the same pattern with their own image_push targets in
platform/discord_bot/BUILD.bazel, platform/twitch_bot/BUILD.bazel, and
platform/youtube_bot/BUILD.bazel.
Next.js apps via Docker
The web apps use multi-stage Docker builds under docker/:
| Dockerfile | App | Runtime | Port |
|---|---|---|---|
docker/backend.Dockerfile | Backend (@elcto/backend) | node:24-alpine3.23, Next.js standalone | 3001 |
docker/id.Dockerfile | ID (@elcto/id) | node:24-alpine3.23, Next.js standalone | 3002 |
docker/policies.Dockerfile | Policies (@elcto/policies) | node:24-alpine3.23, Next.js standalone | 3004 |
Each app build installs dependencies with pnpm install --frozen-lockfile --filter,
builds with pnpm build, and the runtime stage runs under a non-root heimdall
user. All include a HEALTHCHECK.
The Docusaurus docs site is not containerized — it is a static build deployed to Cloudflare Pages (see below).
Push / release
Images are pushed to GitHub Container Registry (ghcr.io) under the
smutjebot/heimdall/* namespace. The image tag is supplied by the
//misc:push_tag build setting (a string_flag defaulting to latest).
# Push the API image (defaults to tag "latest")
just bazel-push-api # bazel run //platform/api:api_push --//misc:push_tag="latest"
just bazel-push-api 2026.6.8 # custom tag
# Push the bot images
just bazel-push-discord-bot
just bazel-push-twitch-bot
just bazel-push-youtube-bot
| Recipe | image_push target | Repository |
|---|---|---|
just bazel-push-api | //platform/api:api_push | ghcr.io/smutjebot/heimdall/api |
just bazel-push-discord-bot | //platform/discord_bot:discord-bot_push | ghcr.io/smutjebot/heimdall/discord-bot |
just bazel-push-twitch-bot | //platform/twitch_bot:twitch-bot_push | ghcr.io/smutjebot/heimdall/twitch-bot |
just bazel-push-youtube-bot | //platform/youtube_bot:youtube-bot_push | ghcr.io/smutjebot/heimdall/youtube-bot |
Each recipe forwards its tag argument to the push: e.g.
bazel run //platform/api:api_push --//misc:push_tag="{{tag}}".
Docs site (Cloudflare Pages)
This documentation site is built with Docusaurus (pnpm build → static
platform/docs/build/) and deployed to Cloudflare Pages via
.github/workflows/deploy-docs.yml (Wrangler). It is not packaged as a
container image. The workflow has three target environments, each a separate
Cloudflare Pages project:
| Environment | Trigger | Pages project | GitHub environment |
|---|---|---|---|
| Staging | push to next (docs changes) or manual dispatch environment=staging | elcto-docs-staging | docs-staging |
| Prod Preview | push to main (docs changes) or manual dispatch environment=prod-preview | elcto-docs-prod | docs-prod-preview |
| Production | clean docs@2* / api@2* release tag (auto) or manual dispatch environment=prod + tag | elcto-docs (→ docs.elcto.com) | docs-prod |
Triggers:
- Automatic on branch push:
next→ staging,main→ prod preview. Thepaths: ['platform/docs/**']filter means a branch push only deploys when docs files changed. - Automatic on release tag: pushing a clean
docs@2*(docs release) orapi@2*(an API release ships docs changes) tag auto-deploys production docs. A pre-job (check-prod-eligible) gates this: only a tag matching exactlydocs@YYYY.M.PATCH/api@YYYY.M.PATCHdeploys — any prerelease suffix (docs@2026.7.1.beta-1,.preview-*,.pre*,-rc*, …) is skipped. The gate runs outside thedocs-prodenvironment, so a prerelease never raises a spurious approval request. Tag pushes ignore thepathsfilter. - Manual (
workflow_dispatch) — modelled onrelease-images: pick the branch via the Actions "Run workflow" ref selector, pick the runtime via theenvironmentdropdown (staging/prod-preview/prod), and (for prod) set atag.staging/prod-preview: deploys the selected branch's content; leavetagempty.prod: setenvironment=prodand atag(2026.6.10, auto-prefixed todocs@2026.6.10, or the fulldocs@2026.6.10). The job cuts & pushes that tag from the selected branch (fails if the tag already exists or the version is malformed), then deploys that branch's docs to production. Empty/invalidtag→ the job fails loudly (no silent skip). The cut tag is pushed viaGITHUB_TOKEN, which does not re-trigger the workflow — so this dispatch cannot cause a second (tag-push) deploy.
Because GitHub environment protection rules evaluate github.ref (the ref the run
executes on), the docs-prod environment's "Deployment branches and tags" allowlist
must include main (manual dispatch runs on the selected branch — typically
refs/heads/main) and the patterns docs@2* + api@2* (automatic
release-tag deploys run on the tag ref). Missing main → manual prod dispatch is
rejected ("Branch main is not allowed to deploy to docs-prod"); missing the tag patterns
→ automatic release-tag deploys are rejected. A required reviewer on docs-prod is
recommended as the primary deploy gate; because the whole manual run (tag cut and
deploy) is one job, a single approval covers both.
The build runs with onBrokenLinks: 'throw' (docusaurus.config.ts), so a single
broken internal link fails the build and blocks the deploy — run
cd platform/docs && pnpm build locally before pushing. Secrets
CF_DOCS_API_TOKEN and CF_DOCS_ACCOUNT_ID are configured per GitHub environment.