Unkey publishes service images from Bazel and publishes the CLI through GoReleaser. Service images and CLI artifacts have separate tags, workflows, and ownership because they serve different release paths.Documentation Index
Fetch the complete documentation index at: https://engineering.unkey.com/llms.txt
Use this file to discover all available pages before exploring further.
Release artifacts
Service releases publish one OCI image per service to GHCR. Each image is multi-architecture and contains only the service binary for that target. A tag of the form<service>/vx.y.z triggers the release workflow, which runs
//build/<service>:push and publishes ghcr.io/unkeyed/<service>:vx.y.z. The
workflow lists the supported service tag prefixes explicitly in its
on.push.tags triggers (api, frontline, vault, heimdall, krane,
control-api, control-worker). Service releases use immutable version tags
only. Do not publish latest or moving release aliases.
The CLI release uses cli/vx.y.z. GoReleaser builds the CLI and publishes the
npm package from that tag. Service tags don’t publish CLI artifacts.
Service entrypoints
Release service binaries live underbuild/<service>. Each entrypoint
is intentionally thin: it creates a standard config command through
build/util, loads the TOML config, and calls the service Run
function.
Runtime defaults and normalization belong inside the service package, not the
entrypoint. For example, instance ID generation, clocks, TLS file loading, and
domain normalization live in svc/.../run.go. This keeps release wrappers from
becoming a second service runtime.
Why Bazel builds the images
Service images are assembled entirely by Bazel. There is no Dockerfile, nodocker build, and no Docker daemon involved while the image is being put
together. The daemon only enters the picture at the very end, when a finished
image is loaded for local use. This choice is what makes the rest of the
design fall into place.
Putting Bazel in charge of the image gives us four properties we care about:
- Content-addressed caching all the way through. Every output is hashed. An unchanged service produces an identical binary, identical tarball, identical layer, and identical image digest. CI does no work and pushes nothing on a no-op change. With a remote cache, a developer’s local build reuses CI’s outputs.
- Reproducibility across machines. Two developers and a CI runner all produce byte-identical images for the same commit, which is what makes the remote cache useful in the first place. A Dockerfile build cannot promise this without significant extra work.
- No privileged Docker in CI. The image is just files on disk written by
normal Bazel actions, so CI does not need a running daemon to build, only
to push, which can be done with
craneoroci_pushover plain HTTPS. - One source of truth for service wiring. The same
BUILD.bazelfiles that compile the binary also declare how it gets shipped. Adding a service is oneservice_imagemacro call rather than a Dockerfile, a build script, and a CI snippet that drift apart over time.
How an image is assembled
The pipeline has four stages, each justified by what the next stage needs:go_binary produces one static ELF. Builds use pure = "on", so there is
no libc and no shared library dependency. The reason this matters: the
distroless base image we ship onto has no dynamic loader available, so any
non-static binary would fail to start. Going static is also what allows the
image to contain a single file at /. No /lib, no /usr, no init scripts.
pkg_tar turns that ELF into a filesystem layer. OCI images are literally
a manifest plus a stack of tarballs, where each tarball is a diff against the
layer below. Adding the binary at /<service> is mechanically equivalent to
producing a tar containing one entry at that path. We use rules_pkg because
it writes the archive with sorted entries, fixed mtimes, and fixed uid/gid.
That determinism is the reason cache hits work. A normal tar invocation
embeds the wall clock and would defeat every layer of caching downstream.
oci_image stacks the layer on distroless_static and writes the
manifest. We use distroless instead of Alpine or Debian for two reasons.
First, attack surface: there is no shell, no busybox, no package manager, so
there is nothing to exploit if a request handler is compromised. Second,
honesty: the image contains exactly what the service needs to run, no more.
This is also why the entrypoint is /<service> rather than ["sh", "-c", ...]. There is no shell to invoke.
oci_image_index produces a multi-arch manifest list. We build amd64 and
arm64 in parallel from the same Go source and bundle them under one logical
image. docker pull resolves to the matching architecture automatically, so
deployments do not need to know whether the cluster is x86 or Graviton. The
local oci_load target only imports the host arch through a select on
@platforms//cpu to keep development fast.
Why the binary outputs to bin/
Each service has two go_binary targets in the same Bazel package, one per
arch, and both write to out = "bin/<service>". The bin/ prefix exists so
that the two arch outputs can share the same final filename without clobbering
each other inside Bazel’s output tree. The image entrypoint is /<service>,
so pkg_tar strips the bin/ prefix when packing the layer, leaving the
binary at the root of the image.
This only works because the pkg_tar rule is defined in the same Bazel
package as the binaries it wraps. pkg_tar’s strip_prefix is resolved
relative to the rule’s own BUILD package, so a bare strip_prefix = "bin"
only matches build/<service>/bin/, which is exactly where the
binary lands. If the image targets ever move out of build/<service>
again, the strip prefix has to become workspace-absolute or the binary will
end up at /build/<service>/bin/<service> inside the image and the
container will fail to start with exec: "/<service>": no such file or directory. Keep the image definition next to the binary.
Bazel image targets
Each service’sBUILD.bazel under build/<service>/ calls the
service_image macro from //build/util:service_image.bzl. Targets in
the service package are:
:image_linux_amd64,:image_linux_arm64: per-arch OCI images:image: multi-architecture index:push: uploads the index to GHCR:load: loads the host-arch image into Docker, taggedunkey/<service>:dev
Service release workflow
.depot/workflows/service-release.yaml handles service tags. The workflow runs
on Depot infrastructure with the shared Bazel remote cache. It does not run
tests. Service releases are cut from main, where the code has already passed
CI. The workflow verifies that the tagged commit is contained in origin/main
before it publishes anything.
When a supported service tag is pushed, the workflow:
- Parses the service name and version from the tag.
- Logs in to
ghcr.iowithGHCR_TOKEN. - Runs
//build/<service>:pushwith stamping. - Creates a GitHub release whose tag is the service tag.
ghcr.io/unkeyed/api:v1.2.3.
CLI release workflow
.github/workflows/release.yaml handles cli/vx.y.z tags. This path still uses
GoReleaser because it owns the CLI packaging and npm publishing flow.
GoReleaser is scoped to the CLI tag prefix in .goreleaser.yaml, so a service
tag does not trigger CLI packaging.
Use this command shape to cut a CLI release:
Local development images
Local Kubernetes and dashboard compose use the same per-service image names as release builds, but with local development tags.| Local environment | Image source |
|---|---|
dev/k8s | Tilt builds unkey/<service>:dev from build/<service> |
| Dashboard compose | make oci-load loads unkey/<service>:dev via Bazel |
--config /etc/unkey/unkey.toml, because the image entrypoint is already the
service binary. Docker compose follows the same rule.
Add a service image
When a new Go service needs its own release image, add it to the release system in one pass.- Add
build/<service>/main.gowith a call toutil.RunServiceCommand. - Add
build/<service>/BUILD.bazelwith Linux AMD64 and ARM64go_binarytargets plus aservice_image(...)call that loads from//build/util:service_image.bzl. Passbinary_nameexplicitly if the in-image binary name differs from the Bazel target prefix (for example when the directory contains a hyphen). - Add the tag pattern (
<service>/v[0-9]+.[0-9]+.[0-9]+*) to theon.push.tagslist in.depot/workflows/service-release.yaml. The workflow then dispatches the tag to//build/<service>:push. - Add a
bazel run //build/<service>:loadline to theoci-loadtarget in the Makefile. - Add local development references in
dev/k8s,dev/Tiltfile, and any compose file that runs the service. - Run
make bazel, then build the image targets.

