Skip to content

Deploy via GHCR

The template ships an autonomous release pipeline. Wire it up once and every merge to main produces a fresh container image Watchtower can pick up.

What the pipeline does

TriggerWorkflowResult
Push to maindocker-publish.ymlghcr.io/<owner>/<image>:latest + sha-<short>
Manual (workflow_dispatch)prepare-release.ymlOpens a prepare-release/vX.Y.Z bump PR (no auto-merge, no tag — a human merges it, then pushes the tag)
vX.Y.Z tag pushedrelease.ymlPushes semver Docker tags, creates GitHub release, posts Discord embed

Rolling latest is handled only by docker-publish.yml. Stable semver tags are handled only by release.yml. The two workflows never collide. The release cadence is manual (fleet policy): you dispatch prepare-release.yml, merge the bump PR, then push the tag.

1. Un-freeze the release pipeline

The release workflows are opt-in via a repo variable — there is no github.repository == guard to swap. Set it on your fork:

bash
gh variable set RELEASE_ENABLED --body true

prepare-release.yml and release.yml gate on if: vars.RELEASE_ENABLED == 'true'. docker-publish.yml has no guard — it runs on any push to main in any clone.

2. Point the image name at your repo

In release.yml and docker-publish.yml, update IMAGE_NAME:

yaml
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: protolabsai/my-agent   # ← lowercase; GHCR is case-sensitive

3. Grant GH_PAT access

prepare-release.yml needs a PAT (not the default GITHUB_TOKEN) to push the release branch so its CI checks fire — branches/tags pushed with the default GITHUB_TOKEN do not trigger downstream workflows, by GitHub's design.

Create a fine-grained PAT with contents: write on the repo, then add it as a secret named GH_PAT in Settings → Secrets → Actions.

4. (Optional) Discord release embeds

release.yml delegates to the shared protoLabsAI/release-tools Action, which reads two CI secrets:

  • GATEWAY_API_KEY — bearer token for the protoLabs LLM gateway; the Action rewrites raw commits into themed notes.
  • DISCORD_RELEASE_WEBHOOK — Discord channel webhook URL. Without it, set post-discord: false (notes generate but aren't posted).

The embed footer/repo link can be customized via the Action's footer and repo inputs — see the release-tools README.

5. Verify the first push

Merge any PR to main (or push a trivial commit). docker-publish.yml should produce:

ghcr.io/<owner>/<image>:latest
ghcr.io/<owner>/<image>:sha-<short>

Check Actions on the repo and Packages on the org for the image.

6. Point Watchtower at latest

On your deploy host (or wherever your compose / IaC lives):

yaml
services:
  my-agent:
    image: ghcr.io/protolabsai/my-agent:latest
    restart: unless-stopped
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
    environment:
      AGENT_NAME: my-agent
      OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
      # REQUIRED for a port published beyond loopback: the server refuses a
      # token-less non-loopback bind (set PROTOAGENT_ALLOW_OPEN=1 instead only
      # if a firewall/network policy fences the port).
      A2A_AUTH_TOKEN: ${A2A_AUTH_TOKEN}
      LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY}
      LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY}
    ports:
      - "7870:7870"
    volumes:
      - audit:/sandbox/audit
      - knowledge:/sandbox/knowledge

  watchtower:
    image: containrrr/watchtower
    command: --interval 60 --label-enable
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Watchtower polls latest every 60 seconds and recreates the container when the image hash changes.

UI tier (ADR 0010): the image defaults to --ui none — API + A2A + /metrics, no console, core deps only (the lean server stack). For the React console in the image, build with --build-arg UI=console; it then runs console. Setup is headless in none — drop a config + OPENAI_API_KEY, the graph compiles on boot (or run --setup); GET /healthz reports readiness. See Sandboxing & egress and the env-vars reference.

7. Cut a release

From the Actions tab, run prepare-release.yml manually and pick patch / minor / major. It opens a chore: release vX.Y.Z bump PR (version + rolled-up CHANGELOG) — no auto-merge, no auto-tag. A human merges it once CI is green, then pushes the vX.Y.Z tag, which triggers release.yml → stable semver Docker tags → GitHub release → Discord post (if configured). See Releasing for the full flow.

Releases are manual / on-demand — merging a PR does not cut a release. See the Releasing runbook for the changelog protocol + the branch ruleset.

Operations

Backup & restore

All durable state lives under one data dir/sandbox in a container, ~/.protoagent otherwise. It holds the SQLite conversation checkpoints (checkpoints.db), the knowledge store (knowledge/), the audit log (audit/audit.jsonl), scheduler jobs (scheduler/<agent>/jobs.db), and the inbox / activity / telemetry / background stores.

The sample compose above persists only audit and knowledge. Because Watchtower recreates the container on every image update, anything not on a mounted volume is lost — conversation history, scheduled jobs, the inbox. For a durable deployment, mount the whole data dir (e.g. data:/sandbox) so every store survives a recreate; the backup below then snapshots all of it.

The SQLite stores run in WAL mode, so a naive tar of a live file can copy a torn page (a write in flight) or miss the -wal / -shm sidecars — producing a backup that won't open. Two safe options:

Cold (simplest, always consistent) — stop the agent, archive, restart:

bash
docker compose stop my-agent          # or Ctrl-C / systemctl stop
tar czf protoagent-$(date +%F).tgz -C /sandbox .   # ~/.protoagent for a non-container run
docker compose start my-agent

Hot (no downtime) — take a consistent snapshot of each live DB with SQLite's online backup, then archive the snapshots:

bash
sqlite3 /sandbox/checkpoints.db ".backup '/tmp/backup/checkpoints.db'"
# repeat for knowledge/*.db and scheduler/<agent>/jobs.db, then tar /tmp/backup + audit/

Restore — stop the agent, replace the data dir from the archive, restart:

bash
docker compose stop my-agent
tar xzf protoagent-2026-06-14.tgz -C /sandbox
docker compose start my-agent

Shutdown semantics

On SIGTERM (docker stop, systemctl stop) or SIGINT (Ctrl-C) the server runs its FastAPI shutdown hooks — spinning down fleet members, stopping the scheduler, and withdrawing mDNS — then uvicorn drains in-flight connections. The graceful drain is bounded to 5s (timeout_graceful_shutdown=5) so the long-lived SSE streams the console holds (chat, runtime status) don't block shutdown forever; past that they're force-closed.

A turn in progress when the signal arrives is cancelled and its work is lost. This is acceptable by design: the A2A task store is durable, so a client that reconnects reconciles the interrupted task via tasks/get and can resubmit. If your turns routinely run longer than the drain window and you want them to finish, raise the container's stop grace period (Compose stop_grace_period, or docker stop -t <seconds>) — it defaults to 10s before SIGKILL.

Part of the protoLabs autonomous development studio.