From 4df7f03896d37b668b13367087112767d813dc06 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 15 Jun 2026 14:00:55 +0530 Subject: [PATCH 1/4] feat(docker): registry images + env-based keeper secrets Add image: refs (ghcr.io/${owner}/volx-:${tag}) to the four app services alongside build:, so the server pulls pre-built images instead of building from source. Switch keeper signer secrets to ${SEPOLIA_RPC_URL}/${PRIVATE_KEY} (from a compose-dir .env or exported shell env) and cloudflared to ${TUNNEL_TOKEN}, dropping the .secrets/*.env file mounts so no source tree is needed on the host. --- docker/docker-compose.prod.yml | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 73c60d3..44f8276 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -12,12 +12,20 @@ # - The API IS published, but only on 127.0.0.1:8090, so a host-installed # cloudflared / local curl still works while staying off the public IP. # -# Bring up: docker compose -f docker-compose.prod.yml up -d --build -# With containerized tunnel: docker compose -f docker-compose.prod.yml --profile tunnel up -d --build +# The four app services carry both `image:` (registry, pulled on the server) +# and `build:` (local). On the server we only `pull` + `up -d`, so the +# pre-built image is used and no source checkout is needed. Locally, +# `up -d --build` rebuilds from source as before. # -# Secrets (gitignored, create on the server): -# ../.secrets/sepolia.env SEPOLIA_RPC_URL + PRIVATE_KEY (keeper signer) -# ../.secrets/cloudflared.env TUNNEL_TOKEN (only if using the tunnel profile) +# Server deploy: docker compose -f docker-compose.prod.yml pull && up -d +# With tunnel: ... --profile tunnel up -d +# Local (build): set -a; . ../.secrets/sepolia.env; set +a; \ +# docker compose -f docker-compose.prod.yml up -d --build +# +# Config — a `.env` beside this file (gitignored) or exported shell env: +# SEPOLIA_RPC_URL, PRIVATE_KEY keeper signer (required) +# TUNNEL_TOKEN only with --profile tunnel +# VOLX_REGISTRY (default ghcr.io/obchain), VOLX_TAG (default latest) name: volx-prod @@ -69,6 +77,9 @@ services: start_period: 5s ingestion: + # Pre-built image pulled from the registry on the server. `build:` is kept + # for local `up --build`; on the server we only `pull` so the image wins. + image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-ingestion:${VOLX_TAG:-latest} build: context: .. dockerfile: crates/ingestion/Dockerfile @@ -89,6 +100,7 @@ services: disable: true engine: + image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-engine:${VOLX_TAG:-latest} build: context: .. dockerfile: crates/engine/Dockerfile @@ -112,6 +124,7 @@ services: disable: true api: + image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-api:${VOLX_TAG:-latest} build: context: .. dockerfile: api/Dockerfile @@ -138,6 +151,7 @@ services: disable: true keeper: + image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-keeper:${VOLX_TAG:-latest} build: context: .. dockerfile: keeper/Dockerfile @@ -146,10 +160,12 @@ services: depends_on: api: condition: service_started - env_file: - # SEPOLIA_RPC_URL + PRIVATE_KEY — create on the server, gitignored. - - ../.secrets/sepolia.env environment: + # Keeper signer secrets come from the compose-dir `.env` (server) or the + # exported shell env (local: `set -a; . ../.secrets/sepolia.env`). Marked + # required so a missing value fails fast instead of starting unsigned. + SEPOLIA_RPC_URL: "${SEPOLIA_RPC_URL:?set SEPOLIA_RPC_URL in .env}" + PRIVATE_KEY: "${PRIVATE_KEY:?set PRIVATE_KEY in .env}" # Reach the API over the compose network (not the host port). VOLX_API_URL: "http://api:8080" # Contract addresses injected directly so the image needs no @@ -163,15 +179,15 @@ services: # Optional containerized Cloudflare tunnel (token-based connector). # Enable with: --profile tunnel. Create the tunnel + public hostname in the # Cloudflare Zero Trust dashboard, map the hostname to http://api:8080, and - # put the connector token in ../.secrets/cloudflared.env as TUNNEL_TOKEN. + # provide TUNNEL_TOKEN via the compose-dir `.env`. cloudflared: image: cloudflare/cloudflared:latest container_name: volx-cloudflared restart: unless-stopped profiles: ["tunnel"] command: ["tunnel", "--no-autoupdate", "run"] - env_file: - - ../.secrets/cloudflared.env + environment: + TUNNEL_TOKEN: "${TUNNEL_TOKEN:-}" depends_on: api: condition: service_started From e6ed189c656e795c3346c647101a76aed92a17ea Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 15 Jun 2026 14:00:55 +0530 Subject: [PATCH 2/4] feat(deploy): pull-based server deploy (no git checkout) Rewrite deploy.sh to pull pre-built images and recreate only the services whose digest changed (docker compose pull && up -d), instead of git-pulling and building on the host. Runs from a standalone dir holding just the compose file, clickhouse-init.sql, deploy.sh, and .env. Optional --tunnel enables cloudflared. --- scripts/deploy.sh | 124 ++++++++++------------------------------------ 1 file changed, 27 insertions(+), 97 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index a9ac7e9..bd61e87 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,112 +1,42 @@ #!/usr/bin/env bash # -# Selective production deploy for VolX. +# Server-side deploy for VolX (registry model). # -# Rebuilds + restarts ONLY the services whose source changed since the last -# deploy, instead of rebuilding the whole stack every time. Intended to run -# on the always-on server from the repo root: +# Pulls the latest pre-built images from the registry and recreates ONLY the +# services whose image digest changed. No source checkout, no on-host build — +# the server holds just this script, the compose file, clickhouse-init.sql, and +# a `.env` with the keeper secrets. # -# ./scripts/deploy.sh +# ./deploy.sh # pull + apply +# ./deploy.sh --tunnel # also (re)start the cloudflared tunnel service # -# State is the commit SHA of the last successful deploy, stored in -# `.last-deployed` (gitignored). On the first run (no state) it does a full -# build + up and records the SHA. -# -# Path -> service mapping: -# api/** -> api -# keeper/** -> keeper -# crates/engine/** -> engine -# crates/ingestion/** -> ingestion -# crates/normalizer|shared-types -> ingestion + engine (shared Rust crates) -# Cargo.toml | Cargo.lock -> ingestion + engine (workspace manifest) -# docker-compose.prod.yml -> recreate full stack (config-level change) -# docker/clickhouse-init.sql -> WARN only (schema loads on cold volume) -# -# Datastores (clickhouse, redis) are official images and are never rebuilt; -# they are only recreated when the compose file itself changes. +# Env (from the `.env` beside the compose file, auto-loaded by compose): +# SEPOLIA_RPC_URL, PRIVATE_KEY keeper signer (required) +# VOLX_REGISTRY (default ghcr.io/obchain), VOLX_TAG (default latest) +# TUNNEL_TOKEN only with --tunnel set -euo pipefail -cd "$(dirname "$0")/.." # repo root +cd "$(dirname "$0")" -COMPOSE_FILE="docker/docker-compose.prod.yml" -STATE_FILE=".last-deployed" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" DC=(docker compose -f "$COMPOSE_FILE") -OLD_SHA="" -[ -f "$STATE_FILE" ] && OLD_SHA="$(cat "$STATE_FILE")" - -echo "==> fetching latest" -git fetch --quiet origin -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -if ! git pull --quiet --ff-only origin "$BRANCH"; then - echo "!! git pull --ff-only failed: the server has local/diverging commits on" >&2 - echo "!! '$BRANCH'. Resolve manually (git status) then re-run. Nothing deployed." >&2 - exit 1 -fi -NEW_SHA="$(git rev-parse HEAD)" - -# First-ever deploy: no state to diff against -> bring up the whole stack. -if [ -z "$OLD_SHA" ]; then - echo "==> no prior deploy state — full build + up" - "${DC[@]}" up -d --build - echo "$NEW_SHA" > "$STATE_FILE" - echo "==> done (initial) @ $NEW_SHA" - exit 0 -fi - -if [ "$OLD_SHA" = "$NEW_SHA" ]; then - echo "==> already at $NEW_SHA — nothing to deploy" - exit 0 -fi - -CHANGED="$(git diff --name-only "$OLD_SHA" "$NEW_SHA")" -echo "==> changed files ($OLD_SHA -> $NEW_SHA):" -echo "$CHANGED" | sed 's/^/ /' - -match() { echo "$CHANGED" | grep -qE "$1"; } - -# Compose change is config-level: recreate the full stack and stop here. -if match '^docker/docker-compose\.prod\.yml$'; then - echo "==> compose changed — recreating full stack" - "${DC[@]}" up -d --build - echo "$NEW_SHA" > "$STATE_FILE" - echo "==> done (full) @ $NEW_SHA" - exit 0 -fi - -# Schema change cannot be applied by a restart (init SQL only runs on a cold -# data volume). Warn loudly; do not touch the data. -if match '^docker/clickhouse-init\.sql$'; then - echo "!! clickhouse-init.sql changed — schema only loads on a COLD volume." - echo "!! NOT applied automatically. Run a manual migration; a full reset" - echo "!! ('docker compose -f $COMPOSE_FILE down -v') DESTROYS all data." -fi - -# Build the set of services to rebuild. Use explicit `if` blocks rather than -# `match ... && SVC[x]=1` so a non-matching `match` can never trip `set -e`. -declare -A SVC -if match '^api/'; then SVC[api]=1; fi -if match '^keeper/'; then SVC[keeper]=1; fi -if match '^crates/engine/'; then SVC[engine]=1; fi -if match '^crates/ingestion/'; then SVC[ingestion]=1; fi +PROFILE_ARGS=() +[ "${1:-}" = "--tunnel" ] && PROFILE_ARGS=(--profile tunnel) -# Shared Rust crates + the workspace manifest/lockfile affect BOTH binaries. -if match '^crates/normalizer/|^crates/shared-types/|^Cargo\.(toml|lock)$'; then - SVC[ingestion]=1 - SVC[engine]=1 -fi +echo "==> pulling images (${VOLX_REGISTRY:-ghcr.io/obchain}, tag ${VOLX_TAG:-latest})" +"${DC[@]}" "${PROFILE_ARGS[@]}" pull -SERVICES="${!SVC[*]:-}" -if [ -z "$SERVICES" ]; then - echo "==> no service-affecting changes (docs/config/etc) — nothing to rebuild" - echo "$NEW_SHA" > "$STATE_FILE" - exit 0 -fi +# `up -d` diffs each service's desired vs running state and recreates only the +# ones whose image (or config) changed — unchanged services are left running. +echo "==> applying" +"${DC[@]}" "${PROFILE_ARGS[@]}" up -d --remove-orphans -echo "==> rebuilding + restarting: $SERVICES" -# shellcheck disable=SC2086 -"${DC[@]}" up -d --build $SERVICES +# Drop any now-dangling old image layers to keep the host tidy. +echo "==> pruning dangling images" +docker image prune -f >/dev/null 2>&1 || true -echo "$NEW_SHA" > "$STATE_FILE" -echo "==> done @ $NEW_SHA (services: $SERVICES)" +echo "==> state:" +"${DC[@]}" ps +echo "==> done" From ca82f3f951756d8e60ed0a238c8880e52b9d1734 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 15 Jun 2026 14:00:55 +0530 Subject: [PATCH 3/4] feat(ci): build+push images to GHCR, then pull-deploy on server Replace the build-on-server deploy with a registry pipeline: a matrix job builds the four backend images and pushes them to GHCR (tags latest + sha) using GITHUB_TOKEN, then the deploy job ships the compose + deploy.sh over Cloudflare SSH and runs the pull-based deploy. No registry or app secrets pass through the deploy step beyond SERVER_SSH_KEY. --- .github/workflows/deploy.yml | 75 ++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b732ca1..7e229dc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,8 @@ name: deploy # Runs after the `ci` workflow succeeds on main (so a red build never deploys), -# or on demand. The actual build happens on the server: this job only opens an -# SSH session through the Cloudflare tunnel and runs scripts/deploy.sh, which -# git-pulls and rebuilds only the services whose source changed. +# or on demand. Builds the four backend images, pushes them to GHCR, then ships +# the compose + deploy script to the server and pulls the new images there. on: workflow_run: workflows: ["ci"] @@ -11,20 +10,61 @@ on: branches: [main] workflow_dispatch: -# One deploy at a time; never cancel a deploy mid-flight (would risk a -# half-rebuilt stack). Queued runs wait their turn. +# One deploy at a time; never cancel mid-flight (would risk a half-applied +# stack). Queued runs wait their turn. concurrency: group: deploy-prod cancel-in-progress: false +permissions: + contents: read + packages: write # push images to ghcr.io//volx-* + jobs: - deploy: - # Gate: only when the triggering CI run passed (manual dispatch has no - # workflow_run payload, so allow it through too). + build: + # Only on a successful CI run, or manual dispatch (no workflow_run payload). if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { svc: api, dockerfile: api/Dockerfile } + - { svc: engine, dockerfile: crates/engine/Dockerfile } + - { svc: ingestion, dockerfile: crates/ingestion/Dockerfile } + - { svc: keeper, dockerfile: keeper/Dockerfile } steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push ${{ matrix.svc }} + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/volx-${{ matrix.svc }}:latest + ghcr.io/${{ github.repository_owner }}/volx-${{ matrix.svc }}:${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.svc }} + cache-to: type=gha,mode=max,scope=${{ matrix.svc }} + + deploy: + needs: [build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Install cloudflared uses: AnimMouse/setup-cloudflared@v2 @@ -55,13 +95,18 @@ jobs: UserKnownHostsFile ~/.ssh/known_hosts EOF - - name: Deploy (build only changed services on the server) + - name: Ship compose + deploy script, then pull & apply + env: + DEPLOY_DIR: /home/ancilar/volx-deploy run: | - ssh ssh.ancilar.com 'bash -se' <<'REMOTE' - set -euo pipefail - cd ~/volx - ./scripts/deploy.sh - REMOTE + ssh ssh.ancilar.com "mkdir -p $DEPLOY_DIR" + scp docker/docker-compose.prod.yml ssh.ancilar.com:"$DEPLOY_DIR/docker-compose.prod.yml" + scp docker/clickhouse-init.sql ssh.ancilar.com:"$DEPLOY_DIR/clickhouse-init.sql" + scp scripts/deploy.sh ssh.ancilar.com:"$DEPLOY_DIR/deploy.sh" + # The server already holds $DEPLOY_DIR/.env (keeper secrets), placed + # once by hand. deploy.sh pulls the freshly-pushed images and recreates + # only the services whose digest changed. + ssh ssh.ancilar.com "cd $DEPLOY_DIR && chmod +x deploy.sh && VOLX_TAG=latest ./deploy.sh" - name: Wipe SSH key if: always() From faaf52991d55d5828f45a526dc19bb55c3a2a775 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 15 Jun 2026 14:04:49 +0530 Subject: [PATCH 4/4] fix(deploy): optional GHCR login for private images; least-privilege perms Source the server .env in deploy.sh and run docker login ghcr.io when GHCR_TOKEN is set, so private packages pull (public still pull anonymously). Scope packages:write to the build job only; deploy gets contents:read. --- .github/workflows/deploy.yml | 10 ++++++---- scripts/deploy.sh | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e229dc..3f3ad33 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,16 +16,16 @@ concurrency: group: deploy-prod cancel-in-progress: false -permissions: - contents: read - packages: write # push images to ghcr.io//volx-* - jobs: build: # Only on a successful CI run, or manual dispatch (no workflow_run payload). if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest timeout-minutes: 30 + # Only the build job pushes images; deploy needs nothing beyond read. + permissions: + contents: read + packages: write strategy: fail-fast: false matrix: @@ -62,6 +62,8 @@ jobs: needs: [build] runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read steps: - uses: actions/checkout@v4 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bd61e87..3ba4054 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -19,12 +19,25 @@ set -euo pipefail cd "$(dirname "$0")" +# Load the local .env so its values are visible to THIS script (compose reads +# it on its own; we need GHCR_TOKEN/GHCR_USER here for the optional login). +if [ -f .env ]; then + set -a; . ./.env; set +a +fi + COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" DC=(docker compose -f "$COMPOSE_FILE") PROFILE_ARGS=() [ "${1:-}" = "--tunnel" ] && PROFILE_ARGS=(--profile tunnel) +# GHCR auth only needed for PRIVATE packages. Public packages pull anonymously; +# set GHCR_TOKEN (a read:packages PAT) in .env if the images are private. +if [ -n "${GHCR_TOKEN:-}" ]; then + echo "==> docker login ghcr.io as ${GHCR_USER:-obchain}" + echo "$GHCR_TOKEN" | docker login ghcr.io -u "${GHCR_USER:-obchain}" --password-stdin +fi + echo "==> pulling images (${VOLX_REGISTRY:-ghcr.io/obchain}, tag ${VOLX_TAG:-latest})" "${DC[@]}" "${PROFILE_ARGS[@]}" pull