Skip to content

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:

cp config/config.yaml config/local.yaml
# 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:

docker compose exec infrasigns wget -q -O - http://localhost:8080/readyz

(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-provisioning is 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, where VERSION is the ARG the caller passes (see below; it defaults to dev)
  • 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=$(git describe --tags --always --dirty) docker compose build

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.