Skip to main content

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

ComponentBuild toolImage source
API (Rust, //platform/api)Bazel + BuildBuddyrules_img image, base @runtime_base
Discord / Twitch / YouTube bots (Rust)Bazel + BuildBuddyrules_img image, base @runtime_base
Backend (Next.js)Dockerdocker/backend.Dockerfile
ID (Next.js)Dockerdocker/id.Dockerfile
Policies (Next.js)Dockerdocker/policies.Dockerfile
Docs (Docusaurus)pnpm buildCloudflare 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:

RecipeRuns
just bazel-buildbazel build //crates/... //platform/... --build_tag_filters=-manual
just bazel-testbazel test //crates/... --build_tag_filters=-manual
just bazel-clippyClippy aspect build (see above)
just bazel-vendorbazel run //vendor:cargo_vendor
just bazel-schemasRegenerates 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 the heimdall-api binary at /usr/local/bin/heimdall-api and copies the default.toml, production.toml, and staging.toml config files into /app/config/.
  • image_manifest (:api_image) uses base = "@runtime_base", entrypoint ["/sbin/tini", "--", "/usr/local/bin/heimdall-api"], runs as user 1001, and sets defaults TZ=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/:

DockerfileAppRuntimePort
docker/backend.DockerfileBackend (@elcto/backend)node:24-alpine3.23, Next.js standalone3001
docker/id.DockerfileID (@elcto/id)node:24-alpine3.23, Next.js standalone3002
docker/policies.DockerfilePolicies (@elcto/policies)node:24-alpine3.23, Next.js standalone3004

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
Recipeimage_push targetRepository
just bazel-push-api//platform/api:api_pushghcr.io/smutjebot/heimdall/api
just bazel-push-discord-bot//platform/discord_bot:discord-bot_pushghcr.io/smutjebot/heimdall/discord-bot
just bazel-push-twitch-bot//platform/twitch_bot:twitch-bot_pushghcr.io/smutjebot/heimdall/twitch-bot
just bazel-push-youtube-bot//platform/youtube_bot:youtube-bot_pushghcr.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:

EnvironmentTriggerPages projectGitHub environment
Stagingpush to next (docs changes) or manual dispatch environment=stagingelcto-docs-stagingdocs-staging
Prod Previewpush to main (docs changes) or manual dispatch environment=prod-previewelcto-docs-proddocs-prod-preview
Productionclean docs@2* / api@2* release tag (auto) or manual dispatch environment=prod + tagelcto-docs (→ docs.elcto.com)docs-prod

Triggers:

  • Automatic on branch push: next → staging, main → prod preview. The paths: ['platform/docs/**'] filter means a branch push only deploys when docs files changed.
  • Automatic on release tag: pushing a clean docs@2* (docs release) or api@2* (an API release ships docs changes) tag auto-deploys production docs. A pre-job (check-prod-eligible) gates this: only a tag matching exactly [email protected] / [email protected] deploys — any prerelease suffix ([email protected], .preview-*, .pre*, -rc*, …) is skipped. The gate runs outside the docs-prod environment, so a prerelease never raises a spurious approval request. Tag pushes ignore the paths filter.
  • Manual (workflow_dispatch) — modelled on release-images: pick the branch via the Actions "Run workflow" ref selector, pick the runtime via the environment dropdown (staging / prod-preview / prod), and (for prod) set a tag.
    • staging / prod-preview: deploys the selected branch's content; leave tag empty.
    • prod: set environment=prod and a tag (2026.6.10, auto-prefixed to [email protected], or the full [email protected]). 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/invalid tag → the job fails loudly (no silent skip). The cut tag is pushed via GITHUB_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.