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 examples/compose-demo on first boot: Sepolia USDC Transfer and Stellar Testnet native XLM (classic Payment, SAC transfer events, SAC transfer invocations). Every match is delivered to the dashboard’s ui-ingest webhook. Pause either network on the dashboard if you only want one pipeline. To attach the dashboard to a cargo run example instead of this stack, see The dashboard.

The stack: docker/compose.yaml

services:
  blockwatcher:
    build:
      context: ..
      dockerfile: docker/Dockerfile.blockwatcher
    command: ["--config", "/etc/blockwatcher/blockwatcher.toml", "--seed", "/opt/compose-demo/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/compose-demo:/opt/compose-demo: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")]
    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/compose-demo/resources, the example’s resource directory bind-mounted read-only from ../examples/compose-demo (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.98-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.98-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/compose-demo’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/compose-demo/resources, the read-only mount of examples/compose-demo/resources: Sepolia and Stellar Testnet networks, their specs, the ui-ingest webhook, and four monitors that all name that sink. --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. Pause a network on the dashboard to stop that pipeline without wiping the store. 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 and STELLAR_RPC_URL to a stellar-rpc URL, the variables the seeded networks’ url_secret name. 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 recent cursors into both seeded networks (each start is absolute, and a stale one replays history):

./examples/compose-demo/setup.sh
docker compose -f docker/compose.yaml up --build -d

The dashboard becomes reachable at http://127.0.0.1:8080 as soon as the ui process binds that port — Compose depends_on: service_healthy only starts the process, it does not wait for ui-ingest to register. Until that sink exists, the published port answers 503 with a waiting page (Retry-After: 3) rather than resetting the TCP handshake. Matches appear there as Sepolia produces USDC transfers and Stellar Testnet produces native XLM payments and SAC transfers. Pause sepolia or stellar-testnet on Networks to run one pipeline. The script-sink contract is a separate example, sink-script-monitor. 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.