Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running with Docker

docker/ ships a two-service Compose stack (blockwatcher itself, plus a companion web UI), meant as a runnable local demo, not a production manifest. It has its own instance config, its own Dockerfiles, and its own .env, and it seeds the sink-script-monitor example on first boot: a usdc-script-test monitor watching USDC Transfer events on Sepolia, delivering every match to the dashboard’s ui-ingest webhook and to a notify-script script sink that appends one line per match to /data/script-sink-events.log inside the blockwatcher container.

The stack: docker/compose.yaml

services:
  blockwatcher:
    build:
      context: ..
      dockerfile: docker/Dockerfile.blockwatcher
    command: ["--config", "/etc/blockwatcher/blockwatcher.toml", "--seed", "/opt/script-sink/resources"]
    env_file:
      - .env
    environment:
      UI_INGEST_URL: http://ui:8080/ingest
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
    volumes:
      - ./blockwatcher.toml:/etc/blockwatcher/blockwatcher.toml:ro
      - ../examples/sink-script-monitor:/opt/script-sink:ro
      - blockwatcher-data:/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 30
    restart: unless-stopped

  ui:
    build:
      context: ..
      dockerfile: docker/Dockerfile.ui
    environment:
      BLOCKWATCHER_API_URL: http://blockwatcher:8080
      BLOCKWATCHER_API_TOKEN: ${BLOCKWATCHER_API_TOKEN:?set it in docker/.env}
      UI_OPERATOR_SECRET: ${UI_OPERATOR_SECRET:?set it in docker/.env}
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
      RUST_LOG: info
      UI_ALLOWED_HOSTS: "127.0.0.1,localhost,[::1],ui:8080"
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ui-data:/data
    depends_on:
      blockwatcher:
        condition: service_healthy
    restart: unless-stopped

volumes:
  blockwatcher-data:
  ui-data:

The container and volume topology (a different picture from The dashboard’s application-level request flow, which this stack’s ui service is the one running) looks like this:

flowchart LR
    subgraph blockwatcher_c["blockwatcher service, no published port"]
        blockwatcherbin["blockwatcher binary"]
        blockwatchervol[("blockwatcher-data<br/>/data/blockwatcher.db<br/>/data/script-sink-events.log")]
    end
    subgraph ui_c["ui service, 127.0.0.1:8080"]
        uibin["ui server"]
        uivol[("ui-data<br/>/data/ui.db")]
    end

    blockwatcher_c -->|"depends_on:<br/>service_healthy"| ui_c
    ui_c -->|"REST proxy<br/>to blockwatcher:8080"| blockwatcher_c
    blockwatcher_c -->|"webhook POST<br/>to ui:8080/ingest"| ui_c

Both services build from the repository root (context: ..) so each Dockerfile can COPY whichever part of the workspace it needs: the blockwatcher service’s build only touches crates/; the ui service’s build touches ui/.

blockwatcher boots with --config /etc/blockwatcher/blockwatcher.toml (the file bind-mounted read-only from ./blockwatcher.toml) and --seed /opt/script-sink/resources, the example’s resource directory bind-mounted read-only from ../examples/sink-script-monitor (what that seed loads, and when, is covered below). env_file: .env is where BLOCKWATCHER_API_TOKEN comes from; UI_INGEST_URL is a value blockwatcher itself never reads at boot: it’s resolved later, at delivery time, once the ui service asks blockwatcher to create a sink. Its healthcheck curls its own /health (the one route the HTTP API serves without a bearer token), five seconds apart, up to 30 times, which is what ui’s depends_on: condition: service_healthy waits on before starting. blockwatcher-data is the one persistent volume: it holds /data/blockwatcher.db, the SQLite store named in blockwatcher.toml below, so pipeline state, resources, and checkpoints survive a docker compose restart or a rebuild. Note there is no ports: entry for this service at all: its API is reachable from ui over the compose network at http://blockwatcher:8080, but not published to the host; reaching it directly from outside the stack means adding a port mapping yourself or running docker compose exec blockwatcher ….

ui is the only service exposed to the host, and only on the loopback interface (127.0.0.1:8080:8080, not 0.0.0.0). BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and UI_INGEST_SECRET are required at compose-parse time (:?set it in docker/.env); the last two must not be the same value.

UI_ALLOWED_HOSTS and UI_INGEST_URL work together, and neither makes sense read alone. On its own first boot (ui/server/src/main.rs), the ui service calls blockwatcher’s API to create a webhook sink named ui-ingest, whose url_secret names UI_INGEST_URL (a reference blockwatcher resolves in its own environment, not the UI’s), which is exactly why the blockwatcher service (not ui) is the one carrying that variable above. From then on, every match any monitor produces that names ui-ingest among its actions gets delivered as a webhook request from the blockwatcher container straight into ui, addressed by compose service name and arriving with Host: ui:8080 (a header this companion checks against an allowlist that defaults to loopback names only) (ui/server/src/guard.rs, ui/server/src/config.rs). Ingest also presents x-blockwatcher-ingest from UI_INGEST_SECRET; operators reach /api with a session from UI_OPERATOR_SECRET. The Host/Origin guard is what keeps a DNS-rebinding page from looking like this host. Without UI_ALLOWED_HOSTS naming ui:8080 explicitly, that legitimate ingest call would be indistinguishable from an attack and rejected identically. ui-data is this service’s own persistent volume, separate from blockwatcher’s: the two services never share a data directory.

The two Dockerfiles

docker/Dockerfile.blockwatcher: a two-stage build. The first stage (rust:1.88-bookworm) copies just Cargo.toml, Cargo.lock, and crates/, then cargo build --release -p blockwatcher: no ui/ in this stage’s build context, since the binary crate doesn’t need it. The second stage (debian:bookworm-slim) installs only ca-certificates (for outbound TLS to an RPC provider) and curl (for the healthcheck above), copies the release binary in, and sets it as ENTRYPOINT. Two stages, not one, is what keeps the shipped image free of the Rust toolchain and the crate source it was built from.

docker/Dockerfile.ui: three stages, because the UI is two components. node:22-bookworm builds the web assets (npm ci, npm run build) from ui/web; a second, independent rust:1.88-bookworm stage builds ui/server; the final debian:bookworm-slim stage copies the server binary and the web build’s dist/ output into one image, EXPOSEs 8080, and sets three environment defaults (UI_STATIC_DIR=/app/static, UI_BIND=0.0.0.0:8080, UI_DB_PATH=/data/ui.db) that the compose file above never overrides, so they’re exactly what the running container uses.

The same two Dockerfiles are what a vX.Y.Z tag pushes to GHCR as ghcr.io/thethirdorigin/blockwatcher and ghcr.io/thethirdorigin/blockwatcher-ui, both tagged with the workspace version. The UI image tag is the release it shipped with, not ui/server’s own crate version.

docker/blockwatcher.toml: the containerized instance config

[api]
enabled = true
listen = "0.0.0.0:8080"

[[auth.tokens]]
label = "compose"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db" }

Two differences from the annotated example on the Configuration reference page are worth calling out. First, [api].listen binds 0.0.0.0, not 127.0.0.1: correct inside a container, where “loopback” means only the container’s own network namespace and would make the ui service’s cross-container call unreachable; the compose file is what keeps this from being exposed carelessly, since blockwatcher publishes no host port at all. Second, there is no [metrics] section here, so the Prometheus scrape endpoint described on the Observability page stays at its default (disabled) in this stack; enabling it means adding the section to this file and, if it should be reachable from outside the container, a ports: mapping in compose.yaml to go with it. There is also no [engine] section, so every engine tunable (channel capacities, drain deadline, retry policy, the matcher module) runs at whatever blockwatcher-core’s own defaults are. docker/ itself carries no resources/ tree: the network, spec, sink, and monitor JSON the stack seeds is examples/sink-script-monitor’s, bind-mounted in, and once the first boot has loaded it the ui service is how those four resource kinds are created and edited against the running blockwatcher API.

Seeding under compose

The command: in compose.yaml passes --seed /opt/script-sink/resources, the read-only mount of examples/sink-script-monitor/resources: one Sepolia network, the USDC ERC-20 spec, the ui-ingest and notify-script sinks, and the usdc-script-test monitor wiring them together. --seed is a one-time, first-boot-only load (it never touches a store that already holds anything, see Seed), so the seed lands exactly once: every later docker compose up boots with whatever blockwatcher-data’s SQLite file already holds, and the running instance is authoritative from then on. A fresh seed therefore takes a docker compose down -v first, which discards both data volumes. To seed a different resource directory instead, point the bind mount (and the --seed path) at it before the first boot, or POST resources through the ui service’s API-proxying UI once it is healthy.

Running it

cp docker/.env.example docker/.env

Edit docker/.env and set BLOCKWATCHER_API_TOKEN to any string, UI_OPERATOR_SECRET and UI_INGEST_SECRET to two different strings (compose interpolates all three with :?set it in docker/.env, and the companion refuses to boot if the last two are equal), and SEPOLIA_RPC_URL to an RPC endpoint, the variable the seeded network’s url_secret names. Any other url_secret a resource names later has to resolve from this same .env too, since env_file puts every variable in it into the blockwatcher container’s environment whether blockwatcher itself defines it or not.

Then stamp a recent start block into the seeded network resource (the seed’s start_block is absolute, and a stale one replays history):

./examples/sink-script-monitor/setup.sh
docker compose -f docker/compose.yaml up --build -d

The dashboard becomes reachable at http://127.0.0.1:8080 once its healthcheck dependency on blockwatcher passes, and matches appear there as Sepolia produces USDC transfers. The script sink’s copy of the same matches is a log inside the blockwatcher container:

docker compose -f docker/compose.yaml exec blockwatcher \
  tail -f /data/script-sink-events.log

Each line carries the full sink event as raw={...} JSON; the README’s Docker walkthrough carries a jq filter that unpacks it into the transfer’s interesting fields. docker compose down -v removes both named volumes along with the containers (and re-arms the first-boot seed); drop -v to keep blockwatcher-data/ui-data across a teardown.