Docker
Quick start
git clone https://github.com/infrasigns/infrasigns
cd infrasigns
cp .env.example .env # fill in LLM_API_KEY and notification tokens;
# DATABASE_DSN already points at the bundled Postgres
# edit config/config.yaml for non-secret settings (sources, schedule, etc.) —
# compose mounts this file; the defaults point at the bundled Prometheus
docker compose up -d
The bundled docker-compose.yml starts four services:
| Service | Image | Port |
|---|---|---|
| infrasigns | built from Dockerfile |
— |
| postgres | postgres:17-alpine |
— (compose network only) |
| prometheus | prom/prometheus:v3.4.0 |
9090 |
| node-exporter | prom/node-exporter:v1.9.1 |
9100 |
Postgres stores its data in the named volume postgres_data and uses fixed
dev-only credentials matching the default DATABASE_DSN in .env.example.
Its port is not published to the host; add a ports: mapping if you need
psql access. For production, point DATABASE_DSN at your own database.
Using your own Prometheus
If you already have Prometheus running, make a config copy, point it at your instance, and skip the bundled one:
# config/local.yaml
sources:
- name: production
url: http://your-prometheus:9090
# Or pull from AWS CloudWatch (type: cloudwatch, region, IAM role),
# DigitalOcean (type: digitalocean, token), or Hetzner Cloud (type: hetzner,
# token); probe any HTTP URL (type: healthcheck, endpoints); or run LogQL
# metric queries against Loki (type: loki, url) —
# see docs/configuration.md#sources.
Then start only InfraSigns (a standalone container still needs a reachable Postgres — pass its DSN):
docker run -v $(pwd)/config/local.yaml:/etc/infrasigns/config.yaml \
-e DATABASE_DSN=postgres://user:pass@your-db:5432/infrasigns?sslmode=disable \
ghcr.io/infrasigns/infrasigns:latest serve --config /etc/infrasigns/config.yaml
(Passing any arguments overrides the image's default command entirely, so
--config must be repeated alongside serve.)
Config volume
The container reads config from /etc/infrasigns/config.yaml. The bundled
compose mounts ./config/config.yaml; to use a different file, override the
mount:
# docker-compose.yml override
services:
infrasigns:
volumes:
- ./config/local.yaml:/etc/infrasigns/config.yaml:ro
The container runs as UID 65532
The image declares USER 65532:65532 — distroless's own nonroot user — so the
process is not root even when nothing on the outside says so
(#585). Earlier images
declared no USER at all and ran as root unless the platform supplied a UID.
A read-only config mount is unaffected as long as the file is world-readable,
which covers the bundled compose and both examples above: a root-owned,
world-readable file (mode 0644) mounted :ro is readable as 65532 — measured,
with the container running and config validate parsing the file. A file whose
mode excludes other is the case two paragraphs down, and :ro does not help it.
A bind mount the container must WRITE to is the case that changes, and
nothing in this repository's own compose files has one. If you have added a
writable mount — a directory for exports, say — a host directory owned by root
with default permissions is no longer writable, and the container will fail on
it where it used to succeed. Either chown 65532:65532 the host directory, or
give the service user: "0:0" if you have a reason to keep running as root (it
overrides the image's USER).
A mounted file the container must READ is the other case, and it is the one
that bit the closed beta (#594).
The paragraph above is about mode 0644; a config file whose mode excludes
other cannot be opened as 65532 at all. 600 is not exotic — it is what a
umask 077 shell, a cp out of a private directory, or an editor saving a file
you think of as holding secrets will give you — and the process then exits 1. Two
lines reach stderr, which is what docker compose logs shows, in this order: the
slog ERROR line first, written before the command returns, and the CLI's
error: block second, written after it (measured by running the binary under
UID 65532 against a root:root mode-600 file at the image's own config path —
not inside a built container):
2026/09/19 12:23:55 ERROR failed to load config error="open config: open /etc/infrasigns/config.yaml: permission denied"
error: load config: open config: open /etc/infrasigns/config.yaml: permission
denied
The errno is permission denied — EACCES, the kernel refusing the open(2)
because mode 600 grants nothing to other. That is the whole diagnosis: the
message names the path and the errno and no UID, so the file's mode is not the
first thing you look at, and searching the docs for the text in front of you is
how you get here. (The second line is wrapped at 80 columns by the CLI, so the
phrase is split there; the slog line above it carries it whole.)
chmod 644 config/config.yaml on the host is the fix.
Whether widening that file is safe depends on what you put in it. The
config/config.yaml this repository ships carries ${VAR} references and no
values — api.token, security.encryption_key, database.dsn, llm.api_key
and the two notify.telegram keys are all '${...}' — so mode 644 exposes
nothing. The values arrive separately, through the compose file's env_file: .env,
which is read by the docker compose CLI as the user who typed the command
(measured: docker compose config against a root:root mode-600 .env fails
for an ordinary user and succeeds under sudo). So nothing in the container ever
opens .env, and this whole section does not apply to it: chmod 600 .env costs
you nothing and is worth doing, because .env.example is mode 644 in this
repository and the quickstart's cp carries that through under an ordinary
umask 022 (measured: 644 at umask 022, 600 at umask 077). Widen the config
file, never the environment file.
If you took config.yaml's own offer to "copy this file and replace '${VAR}'
with literal values", the file holds secrets and chmod 644 is the wrong move:
chown 65532:65532 it instead and leave the mode alone, or give the service
user: "0:0".
What the compose healthcheck means
The bundled compose probes /readyz (wget --spider, every 10s), which is what
docker ps's STATUS column, docker compose up --wait and any
depends_on: condition: service_healthy read.
It is a start-marker, not a dependency check. /readyz answers 200 for
every process that can answer at all — nothing on it gates, so an unreachable
Prometheus, an SMTP host that will not resolve and a database that will not
answer a Ping all leave the container healthy
(#507, and see
Readiness). That is deliberate: a monitoring
stack whose monitored target is down is working, not broken. It does mean a
green healthy says only that the process is serving.
The fault signal is the body, not the status code:
(The image is distroless and has no shell, so that is wget directly rather
than a piped one-liner. wget is the static busybox binary the Dockerfile
copies to /usr/local/bin for the healthcheck above, and the tracked compose
publishes no host port for this service, so exec is how you reach it.)
Its top-level status reads warning whenever anything under it is not ok,
and each entry carries its own error. If you scrape the container, the
machine-readable form is
infrasigns_readiness — alert on == 0.
Keeping this healthcheck is also what keeps that gauge moving: it is written by
the probe, so a compose stack with the healthcheck removed reports no readiness
series at all.
Upgrading
There is no pre-upgrade hook here. serve applies its own migrations and then
starts, so a new version that refuses your configuration refuses it in the
running container — and with restart: unless-stopped that is a restart loop
with nothing being monitored, where the Helm chart would have failed a Job and
left the old Deployment alone. So the pre-flight is a command you run, and it is
one command:
docker run --rm \
-v $(pwd)/config/config.yaml:/etc/infrasigns/config.yaml:ro \
--env-file .env \
ghcr.io/infrasigns/infrasigns:<the version you are upgrading TO> \
config validate --strict-provisioning --config /etc/infrasigns/config.yaml
Read its exit code as three answers, not two. 0 is ✓ Config valid; 1 is a
refusal under ✗ Config invalid or ✗ This deployment cannot store this config
file's sources, and is ALSO what a file the command could not open at all earns,
under ✗ Could not read the config file — the verdict line is what separates
those two, because the code does not; 3 is
! Config valid under the rules this run could apply — nothing
refused, and a set of rules skipped because this shell could not resolve the sign-in
client id, or because the file's own bytes are not YAML. Add --accept-unjudged if
3 is not a failure for your gate, which it usually is not for a CI job that lints the
file and holds no sign-in secret. The three are laid out in
Pre-flight before an upgrade.
Three things about that invocation are load-bearing:
- the image is the NEW version, because the point is to run the new rules against your current file;
--env-file .env, because the rules expand${VAR}before they judge — run it without your environment and every${PROM_TOKEN}is judged as the empty string, so it answers for a document you are not deploying;--strict-provisioningis accepted and implied, and is kept in the line above only because a released image older than this one needs it. The command exits 1 on a provisioning refusal on its own: that answer is decidable from the file alone, and a judge that certifies a document the daemon will not start on is worse than no judge. It stays scoped to those refusals — the cross-reference warnings still do not fail it, because those may legitimately be answered by a source that lives in the store.
Non-zero means fix the file first. config validate opens no database, so it is
safe to run against a live deployment, and running it for a bare binary is the
same command without the docker run wrapper.
An upgrade is not the only way to arrive here. These rules bind whenever the
deployment configures sign-in, so adding an auth: block to a file that was
otherwise unchanged meets them for the first time, on the image you are already
running — same refusal, same restart loop. Add the block, then run the command
above with the tag you are running now.
The refusals it reports are the ones a release note marks Breaking;
Where a source lives explains why a
config file has to pass the database's write gate at all, and
Rolling back says
what a downgrade does and does not undo. Both are about a deployment with sign-in
configured. The bundled config/config.yaml has no auth: section, so on the
quick-start stack above the command exits 0 with nothing to say — which is a fact
about that configuration and not a general clean bill.
Building locally
make docker-build # computes the version below, and sets the OCI labels
docker build -t infrasigns:dev . # builds the same way, but the binary reports `dev` and the image carries no labels
The image uses a multi-stage build:
- Stage 1 (
golang:1.26-alpine) — compiles the binary with-ldflags="-s -w -X …/internal/cli.version=${VERSION}" -trimpath, whereVERSIONis theARGthe caller passes (see below; it defaults todev) - Stage 2 (
gcr.io/distroless/static-debian12) — minimal runtime, no shell
Telling which build you are running
infrasigns version prints the edition and the version the binary was stamped
with. Left unstamped it prints dev, which is honest and not useful — a bug
report from such a binary cannot say which commit produced it, and the image's
creation date is not identity (it moves whenever anyone rebuilds an older
commit).
What each way to build reports:
| how you build | what it reports |
|---|---|
the published image (ghcr.io/infrasigns/infrasigns) |
the release tag, or the commit sha for a main build |
make build, make docker-build |
git describe --tags --always --dirty — the tag on a tagged commit, otherwise <tag>-<n>-g<sha>, with -dirty appended when a tracked file has uncommitted changes |
docker compose build / up --build |
dev, unless you pass the value (below) |
a bare go build ./cmd/infrasigns |
dev — nothing passes the linker flag; use make build, which stamps it (and strips symbols and trims paths, as the image build does) |
compose cannot run git for itself, so give it the value:
INFRASIGNS_VERSION can also live in .env, which compose interpolates from —
but it is then a fixed string that goes stale on the next commit, so prefer
passing it on the command line. The same value is written to the image's
org.opencontainers.image.version label, so docker inspect answers for anyone
holding the image without a shell in it. make docker-build additionally sets
org.opencontainers.image.revision to the full commit, and only from a tree with
no uncommitted changes to a tracked file.
Two limits worth knowing before you trust either answer. Untracked files are
invisible to both — that is what --dirty means in git, but the Go toolchain
compiles untracked .go files and the image copies them in, so a build from a
tree carrying untracked source can report a clean version. And the published
image labels itself differently: its labels come from the release workflow, so a
main build carries org.opencontainers.image.version=latest while the binary
inside reports the commit sha. For the published image the binary is the precise
answer; for a local build the two agree.