Docker
Pick a stack, put two lines in a .env file beside it, and start it.
Every combination here is written by one generator, and CI fails when a file no longer matches it.
# ghchronicle, with InfluxDB and Grafana.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Grafana is on http://localhost:3000,# admin and the password below, with the dashboard already in it: the# collector publishes it on start and points it at the store beside it.
name: ghchronicle
services:
influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30
grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true state_file: /var/lib/ghchronicle/state.json
volumes: influx: state: grafana:# ghchronicle, with InfluxDB.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Nothing is published to Grafana;# the store is yours to point one at.
name: ghchronicle
services:
influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github state_file: /var/lib/ghchronicle/state.json
volumes: influx: state:# ghchronicle, with PostgreSQL and Grafana.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Grafana is on http://localhost:3000,# admin and the password below, with the dashboard already in it: the# collector publishes it on start and points it at the store beside it.
name: ghchronicle
services:
postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30
grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true datasource: sslmode: disable state_file: /var/lib/ghchronicle/state.json
volumes: postgres: state: grafana:# ghchronicle, with PostgreSQL.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Nothing is published to Grafana;# the store is yours to point one at.
name: ghchronicle
services:
postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable state_file: /var/lib/ghchronicle/state.json
volumes: postgres: state:# ghchronicle, on its own.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. It writes what it collects to its own# log, which is enough to watch it work. Point sinks at a store of your# own when you have one.
name: ghchronicle
services:
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: stdout: true state_file: /var/lib/ghchronicle/state.json
volumes: state:# ghchronicle, on its own.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. It writes what it collects to its own# log, which is enough to watch it work. Point sinks at a store of your# own when you have one.
name: ghchronicle
services:
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: stdout: true state_file: /var/lib/ghchronicle/state.json
volumes: state:docker compose up -dThe stack’s state volume keeps the state and the cache from one start to the
next, and it is the collector’s to write from the first: the image carries the
directory it is mounted on, owned by the uid the collector runs as, and Docker
gives a new volume that owner. See what has to be
writable.
The first sweep runs every family, since nothing has run yet, except that the
collector starts one family of six hours or more a sweep: traffic, stats,
forks and the rest of the eleven reach the store over the first two and a
half hours, and only the first time. See the slow families take
turns.
With Grafana it is on http://localhost:3000, user admin,
password ghchronicle unless you set GRAFANA_PASSWORD. The dashboard is
already there: the collector publishes it when it starts and points it at the
store beside it, so there is nothing to import and no datasource to fill in.
The rest of this page is the image itself, for a reader running it another way.
One container, by hand
Section titled “One container, by hand”docker run -d --name ghchronicle \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle \ -e GITHUB_TOKEN -e INFLUX_TOKEN \ -p 9605:9605 \ ghcr.io/jmrplens/ghchronicle -config /config.yamlWith state_file: /var/lib/ghchronicle/state.json in the configuration, which
is where the example configuration puts it. The volume is what keeps the state
and the cache across a new container, and Docker creates it on first use,
owned by uid 65532 like the directory it is mounted on; what has to be
writable says why each is needed.
The image
Section titled “The image”Built FROM gcr.io/distroless/static-debian13:nonroot over a static,
CGO_ENABLED=0 binary. There is no shell and no package manager in it, so code
execution inside the container has nothing to pivot with, and the nonroot tag
bakes in uid 65532, which keeps it off root even when the orchestrator sets
no securityContext of its own.
Two consequences worth knowing before you debug it:
docker exec ... shdoes not work. There is nosh. Read the logs instead.- Anything the container writes must be owned by, or writable by, uid 65532.
Verify the image
Section titled “Verify the image”Every released image from 2.5.1 on, which is every image a release page names, is signed with cosign, keylessly and by the same identity as the checksum file the other installs check: this repository’s release workflow, run for a release tag, recorded in a public transparency log. With cosign 3:
cosign verify \ --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/jmrplens/ghchronicle:v2.6.5 > /dev/nullVerification for ghcr.io/jmrplens/ghchronicle:v2.6.5 --The following checks were performed on each of these signatures: - The cosign claims were validated - Existence of the claims in the transparency log was verified offline - The code-signing certificate was verified using trusted certificate authority certificatesThe Docker Hub copy, docker.io/jmrplens/ghchronicle, verifies with the same
command. What cosign checks is a digest, not the tag: without > /dev/null it
also prints, as JSON, the digest it verified (docker-manifest-digest), and an
image pulled as ghcr.io/jmrplens/ghchronicle@sha256:... is the one verified
however a tag moves afterwards. latest is moved onto a release’s digest last,
once the release workflow has verified that digest’s signature and run the
image on both architectures, so it never names an image that was not checked;
a release that fails before then leaves it on the previous one.
Use cosign 3. The signatures are in the bundle format it writes, and an older
cosign answers no signatures found for these images: measured, cosign 2.6.1
finds the signature only when given --new-bundle-format, 2.5.0 does not find
it even then, and 2.4.2 does not know the flag. Images up to 2.5.0 carry no
signature at all, so for them that answer is the true one.
What has to be writable
Section titled “What has to be writable”The config file is mounted read-only. Six things are not:
| Path | Needed for |
|---|---|
state_ | Always. Without a persistent path the stargazer walk repeats on every restart |
<name>- | Always. The cache, which sits beside the state file and has no setting of its own |
sinks. | The long-running service, with a sink that keeps it. A -once run opens none |
<name>- | The long-running service, which holds it while it runs, and -migrate -yes |
sinks. | Only with the file sink |
log. | Only with a log file configured |
The first three live in the same directory by default, and so does the lock, so one mounted directory covers them, and it has to be a directory. Each of the three is written beside itself and renamed into place, so a reader never sees half a file, and a file mounted on its own cannot be renamed over: measured with the 2.6.1 image built from its Dockerfile, a state file bind-mounted alone was never saved, and every sweep said so.
level=WARN msg="state not saved" err="rename /var/lib/ghchronicle/state.json.tmp /var/lib/ghchronicle/state.json: device or resource busy"A directory the collector cannot write gives the same warning, with the error that fits, and one for the cache beside it:
level=WARN msg="state not saved" err="open /var/lib/ghchronicle/state.json.tmp: permission denied"level=WARN msg="cache file not saved" file=/var/lib/ghchronicle/state-cache.bin err="open /var/lib/ghchronicle/state-cache.bin.tmp: permission denied"From 2.6.1 the image carries /var/lib/ghchronicle owned by uid 65532, and
Docker fills a new named volume from the directory it is mounted over, owner
included, so the directory the example configuration names needs nothing done
to it: measured with a fresh volume, and with an empty one the 2.6.0 image had
left to root, the collector wrote its state and its cache into both with no
warning. With nothing mounted there it writes too, and the state goes with the
container. Up to 2.6.0 the image had no such directory: nothing mounted there
left one uid 65532 could not create, and a new named volume there was root’s,
which is where the two lines above came from.
Two ways are left to end up with a directory the collector cannot write. A
named volume mounted where the image has no directory of its own is created
owned by root: hand it to uid 65532 once, with any image that has a chown,
as in docker run --rm -v <volume>:/v alpine chown 65532:65532 /v. A host
directory is mounted as it is, whoever owns it: sudo chown 65532:65532 it on
the host. A configuration that names no state_file at all writes to the
container’s working directory, which is writable and goes with the container.
Without the state, every new container starts from nothing: the stargazer walk, the whole star history and the co-authored walk again, and every family at once. Without the cache, a whole sweep of asking GitHub again what it already knew: the cache beside it. Without the ledger, a whole sweep of rewriting, which is the one thing it exists to prevent: only what changed is written.
The port
Section titled “The port”EXPOSE 9605 is the Prometheus exporter, and it is the only listener the
process ever opens. Publish it only if you enabled the prometheus sink; every
other sink is outbound.
With compose
Section titled “With compose”services: ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN} INFLUX_TOKEN: ${INFLUX_TOKEN} volumes: - ./config.yaml:/config.yaml:ro - state:/var/lib/ghchroniclevolumes: state:services: influxdb: image: influxdb:3-core volumes: - influx:/var/lib/influxdb3 ports: - "8181:8181"
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: - influxdb environment: GITHUB_TOKEN: ${GITHUB_TOKEN} INFLUX_TOKEN: ${INFLUX_TOKEN} volumes: - ./config.yaml:/config.yaml:ro - state:/var/lib/ghchronicle
volumes: influx: state:The collector reaches the database by service name, so
sinks.influxdb.url is http://influxdb:8181.
After an upgrade
Section titled “After an upgrade”Pull the new image and recreate the container. Under the default
migrate: auto, its start applies on its
own, before its first sweep, every change the new release carries that loses
nothing, and warns about the rest at every start with the two commands that
apply them: see Migrations. The
container takes the same flags as the binary, so those run in a one-off
container of the same service, with its volume, its environment and its user,
while the service is stopped:
docker compose pull ghchronicledocker compose up -d ghchronicledocker compose logs ghchronicle | grep 'migration pending'docker compose stop ghchronicledocker compose run --rm ghchronicle -config /config.yaml -migratedocker compose run --rm ghchronicle -config /config.yaml -migrate -yesdocker compose start ghchronicleThe first run prints the plan and changes nothing; the second applies it and
reads back what it cleared. Both mount the volume the service mounts, so they
find the lock it holds beside the state file: with the service still running,
-migrate -yes refuses, naming the process by the number the service’s own
container gives it, and changes nothing. Measured with two containers on one
named volume: a flock one of them held was refused to the other, and granted
once the first had gone. A one-shot container a scheduler runs, below, holds
no lock while it sweeps, so pause the scheduler for the length of the two runs.
One sweep, then exit
Section titled “One sweep, then exit”The container takes the same flags as the binary, so a scheduler can run it without a long-lived service.
docker run --rm \ -v /etc/ghchronicle/config.yaml:/config.yaml:ro \ -v ghchronicle-state:/var/lib/ghchronicle \ -e GITHUB_TOKEN \ ghcr.io/jmrplens/ghchronicle -config /config.yaml -onceMount the state volume in this mode too. It is what makes the second run cheap, and what keeps each family to its own cadence: without it every run collects every family.