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.