# Docker

Running ghchronicle in Docker: the distroless image, what has to be mounted writable, and a compose file next to InfluxDB.

Source: https://docs.jmrp.io/ghchronicle/install/docker/

Pick a stack, put two lines in a `.env` file beside it, and start it.

## InfluxDB and Grafana

```yaml
# 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:
```

## InfluxDB

```yaml
# 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:
```

## PostgreSQL and Grafana

```yaml
# 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:
```

## PostgreSQL

```yaml
# 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:
```

## The collector on its own

```yaml
# 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:
```

```sh
docker compose up -d
```

The 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](https://docs.jmrp.io/ghchronicle/install/docker/#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](https://docs.jmrp.io/ghchronicle/configuration/cadences/#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

```sh
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.yaml
```

With `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](https://docs.jmrp.io/ghchronicle/install/docker/#what-has-to-be-writable) says why each is needed.

## 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 ... sh` does not work. There is no `sh`. Read the logs instead.
- Anything the container writes must be owned by, or writable by, uid 65532.

## 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](https://docs.sigstore.dev/cosign/system_config/installation/) 3:

```sh
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/null
```

```text
Verification 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 certificates
```

The 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

The config file is mounted read-only. Six things are not:

| Path                | Needed for                                                                        |
| ------------------- | --------------------------------------------------------------------------------- |
| `state_file`        | Always. Without a persistent path the stargazer walk repeats on every restart     |
| `<name>-cache.bin`  | Always. The cache, which sits beside the state file and has no setting of its own |
| `sinks.dedupe_file` | The long-running service, with a sink that keeps it. A `-once` run opens none     |
| `<name>-lock`       | The long-running service, which holds it while it runs, and `-migrate -yes`       |
| `sinks.file.path`   | Only with the file sink                                                           |
| `log.file`          | 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.

```text
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:

```text
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](https://docs.jmrp.io/ghchronicle/configuration/#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](https://docs.jmrp.io/ghchronicle/sinks/#only-what-changed-is-written).

## 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.

> **Bind the exporter to 0.0.0.0 inside a container**
>
> The example configuration listens on `127.0.0.1:9605`, which inside a
> container means the container's own loopback and is unreachable from the host.
> Set `sinks.prometheus.listen: 0.0.0.0:9605` and let `-p` decide who can reach
> it.

## With compose

- **Collector only**

  ```yaml title="compose.yaml"
  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/ghchronicle
  volumes:
    state:
  ```

- **With InfluxDB**

  ```yaml title="compose.yaml"
  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

Pull the new image and recreate the container. Under the default
[`migrate: auto`](https://docs.jmrp.io/ghchronicle/configuration/#migrate), 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](https://docs.jmrp.io/ghchronicle/install/upgrading/#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:

```sh
docker compose pull ghchronicle
docker compose up -d ghchronicle
docker compose logs ghchronicle | grep 'migration pending'
docker compose stop ghchronicle
docker compose run --rm ghchronicle -config /config.yaml -migrate
docker compose run --rm ghchronicle -config /config.yaml -migrate -yes
docker compose start ghchronicle
```

The 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

The container takes the same flags as the binary, so a scheduler can run it
without a long-lived service.

```sh
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 -once
```

Mount 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.
