Skip to content

Documentation

Docker Compose

Run Syrus on a single machine (web + worker, SQLite) with one command.

Docker Compose

Docker Compose is the lowest-friction way to run the real Syrus app on a single machine — your Mac, a laptop, a small box — without a Kubernetes cluster or a MySQL server. The default path pulls a prebuilt image; the source-build path is there when you are changing Syrus or need custom system packages. Either way, you get the web UI, the background worker, the full agent toolchain, and the GitHub issue-to-PR loop, backed by SQLite.

This is a single-host setup. The repository's Dockerfile also backs the clustered MySQL deployment; the Compose path layers docker-compose.yml, Dockerfile.local, compose.env.example, and bin/compose-up around it for local SQLite operation.

1. Install a container runtime

macOS doesn't run Linux containers natively, so install a runtime. Either works — pick one:

brew install orbstack            # fast, lightweight, recommended — bundles Compose
# or:
brew install colima docker docker-compose && colima start

Colima needs the Compose plugin installed separately (docker-compose); a plain brew install docker gives you the docker CLI but not the docker compose subcommand. bin/compose-up detects whichever is present (docker compose or docker-compose).

2. Bring it up with the prebuilt image

From a checkout of the repo:

./install.sh --docker

The installer:

  1. Starts or installs a container runtime when needed.
  2. Generates .env from compose.env.example with fresh secrets

(SECRET_KEY_BASE and the three Active Record encryption keys) on first run. .env is gitignored — keep it.

  1. Pulls ghcr.io/tkadauke/syrus-backend. On a rerun, an existing

SYRUS_IMAGE pin in .env is honored unless --image or the process environment explicitly overrides it.

  1. When the pulled image ID differs from the existing web container's image ID,

stops the stack and archives the data volume under backups/ before any database migration can run. A retained data volume with no container to compare (after docker compose down, for example) is always backed up.

  1. Starts the stack: a one-shot setup task (prepares the SQLite

databases and fixes volume ownership), then web and worker.

When it finishes, open http://localhost:3000. The web port binds to 127.0.0.1 by default, so only this machine can reach the first-account flow. The first signup becomes the admin, and the first-run wizard walks you through GitHub credentials, the agent, a repository, and a guided chat to land your first Epic.

docker compose logs -f web worker   # follow logs
docker compose down                 # stop
./install.sh --docker               # pull updates and restart

Android emulator support

The published worker image includes the Android SDK command-line baseline used by the Android plugin: command-line tools, platform-tools, emulator, selected platform/build-tools, and accepted licenses. That is enough for Android Gradle tasks that do not boot an emulator.

Emulator-backed graders and Runtime Sessions also need host acceleration. The host must expose KVM, and the worker container must be allowed to use it. On a single-host Compose install, add a worker device mapping when the host has /dev/kvm:

services:
  worker:
    devices:
      - /dev/kvm:/dev/kvm

If Syrus runs inside a VM, nested virtualization must also be enabled in that VM. Without these pieces, Android unit/lint/build tasks may still work, but emulator launches usually fail with /dev/kvm: Permission denied, KVM is required to run this AVD, or x86 emulation currently requires hardware acceleration.

Driving the installer from automation

install.sh --docker doubles as a headless installer — the Syrus macOS desktop app drives this exact script during its first-run setup. All flags are optional; without them the behavior above is unchanged.

FlagEffect
--non-interactiveNever prompt; a missing decision is a usage error (exit 2).
--jsonMachine-readable NDJSON events on stdout (start, step, log, error, done); human-readable output moves to stderr.
--target-dir DIRDirectory that owns mutable state: .env plus a synced copy of docker-compose.yml. Compose runs from there. Default: the script's own directory (the clone).
--skip-runtime-installNever install Homebrew/OrbStack. An installed-but-stopped runtime is still started; with no runtime at all the script exits 10.
--image REFPin SYRUS_IMAGE to a specific tag. The pin is persisted into .env so later plain docker compose up runs use the same tag.
--port NFirst install only: serve on this port instead of 3000 (sets SYRUS_PORT and SYRUS_APP_HOST during .env generation).
--allow-fresh-dataProceed even when this install's data volume is found on a *different* Docker context (exit 21). Say this only when a second, independent, empty instance is the goal.

step events carry an id from this fixed sequence: runtime_check, runtime_install, runtime_start, compose_resolve, env_check, env_generate, image_pull, stack_up, health. During stack_up, an image change may emit backup log events while the stopped data volume is archived. The final health step polls the app's /up endpoint, so done means the web UI actually answers, not just that containers were created.

Failures are classified by exit code so a driving process can react without parsing text:

Exit codeMeaning
0Success — the app answers on its port.
2Usage error (unknown flag, missing mode in non-interactive runs).
10No container runtime and --skip-runtime-install was set.
11A runtime exists but its daemon never became ready.
12Docker Compose is not available.
20The syrus_syrus-data volume exists but .env is missing — the encryption-key guard. Restore the original .env or wipe with docker compose down -v.
21This install has an .env, but its syrus_syrus-data volume lives on a different Docker context — the wrong-daemon guard. See When Syrus looks empty after an update. Override with --allow-fresh-data.
30Image pull failed for a network or other unclassified reason, and no local copy of the image exists.
31The registry denied the pull and no local copy exists. The installer already handles the most common cause itself — a stale saved Docker login (docker sends any stored ghcr.io credentials with every pull, and an expired token is rejected even for public images) — by running docker logout for the registry and retrying, once per run. A 31 that persists means the package is private, the tag is unpublished, the registry genuinely requires a login, or Docker's credential helper is broken — check ~/.docker/config.json for a stale credsStore/credHelpers entry.
32The image tag does not exist in the registry and no local copy exists.
40docker compose up failed.
41The stack started but never became healthy.
42The existing stack could not be stopped or its data volume could not be backed up before an image change; the new image was not started.

The pull is retried twice before failing, and a failure that matches the stale-saved-credential patterns triggers one automatic docker logout <registry> before the retry re-pulls anonymously — no manual docker logout ghcr.io needed. If the pull still fails but the image already exists in the local Docker image store — a previous install, or a copy you built yourself — the installer continues with that local copy instead of exiting with a pull-failure code (30/31/32). That makes fork development and offline reinstalls work without registry access: docker build/bin/compose-up the image under the pinned name, then run the installer normally.

One .env owns an installation. If you first installed from a clone (.env at the repo root) and later point an automated install at a different --target-dir, the guard exits 20 because the data volume exists but that directory has no .env — copy your original .env into the target directory to adopt the existing installation.

When Syrus looks empty after an update

If an update or reinstall drops you at /onboarding with every Job, Epic and chat apparently gone, your data is almost certainly still there. The usual cause is that a second container runtime took over the active Docker context.

OrbStack, Docker Desktop and Colima each claim currentContext in ~/.docker/config.json when they start. Compose follows that context, so a newly launched runtime sends the installer to a daemon that has none of your state. It finds no volume and no network, creates both from scratch, migrates a blank database, and hands you a pristine instance sitting beside the real one. Nothing is deleted — the two just live in different VMs.

The installer now stops with exit 21 when it can see your volume on another context, and names it. To check by hand:

docker context ls                     # which contexts exist; * marks the active one
docker context use colima             # switch to the one that owns your data
docker volume ls | grep syrus-data    # confirm the volume is here

Then re-run the update, or bring the stack up directly:

cd ~/.syrus/local && docker compose -p syrus up -d

Confirm you have your data back before doing anything else:

docker exec syrus-web-1 sqlite3 /home/rails/.syrus/db/production.sqlite3 \
  "select count(*) from jobs"

Back up before any upgrade that migrates a database you care about. The SQLite file and the .env must be kept together — the .env holds the Active Record encryption keys, and either one alone is useless:

docker exec syrus-web-1 sqlite3 /home/rails/.syrus/db/production.sqlite3 \
  ".backup /tmp/backup.sqlite3"
docker cp syrus-web-1:/tmp/backup.sqlite3 ./production.sqlite3
cp ~/.syrus/local/.env ./env-backup

When the volume is missing and *no* other context has it either, the installer only warns and continues — that case is ambiguous (a first install that died after writing .env, or a deliberate docker compose down -v wipe), and refusing would make a half-finished install unrecoverable. If you see starting empty in the installer output and did not expect it, stop and check whether the runtime holding your data is actually running.

Build or customize the image

From a checkout of the repo:

bin/compose-up

That script:

  1. Generates .env if it does not exist, using the same local secrets

rules as the prebuilt installer.

  1. Builds the base worker image — the fat agent toolchain (Ruby/Node/Go via

mise, Python + poetry/uv, build tools, db clients, external agent CLIs, and the syrus CLI). The first build is slow (it compiles language runtimes); later builds are cached.

  1. Builds Dockerfile.local on top of that base image, applying

EXTRA_APT_PACKAGES when present.

  1. Starts the same Compose stack.

Use this path when you are changing Syrus source or need apt packages in the worker image. If you only want to run the app, use ./install.sh --docker.

docker compose logs -f web worker   # follow logs
docker compose down                 # stop
bin/compose-up                       # restart / pick up changes

What runs

  • web — Rails app served by Thruster on localhost:3000.
  • worker — Solid Queue worker: pollers, prepare steps, agent runs,

pushes, PR creation, reapers, and workspace pruning.

  • setup — a one-shot container that runs db:prepare and makes the data

volume writable by the rails user, then exits. web and worker wait for it.

  • plugin-runtime — Plugin Runtime's manager. Idle until you enable a

plugin that runs a service of its own, such as Git Mirror; it then pulls and starts that plugin's container, and removes it again when the plugin is disabled, without restarting Syrus. It is the only container given the Docker socket, runs only images published by Syrus, and takes its plugin containers down with it when the stack stops. If its image can't be pulled during an install, Syrus starts without it and those plugins stay off. Admin → Plugin Services lists those containers and lets an admin stop, start, or restart them and read their logs, and delete the stored data a disabled plugin left behind. Tailscale is the one exception worth knowing about: it needs NET_ADMIN/NET_RAW/a TUN device, which the manager never grants to a normal plugin service, so it runs through a separately named, narrowly scoped privileged lane instead — still started by plugin-runtime, still shown on Admin → Plugin Services (with a Privileged badge), but the worker container itself carries no elevated network capability. See the Tailscale entry on the Features page.

  • syrus-data volume — /home/rails/.syrus, holding the primary **SQLite

databases** (db/production*.sqlite3) and the clone cache / workflow workspaces.

  • syrus-search volume — /home/rails/.syrus-search, holding the dedicated

SQLite FTS5 search database at search.sqlite3.

Both volumes are persisted across restarts; losing them wipes the local installation state.

There is no MySQL container and no master key — local mode runs the production environment against SQLite (SYRUS_SQLITE=1) and provides the encryption keys via environment variables.

Extending the worker environment

The worker runs your repositories' prepare / grader commands, so it needs their toolchains. Two layers cover this:

  • Language runtimes — already handled, per-repo, with no image changes.

mise is in the image; put a .mise.toml / .tool-versions in your repo and add mise install to its .syrus.yml prepare:. Out of the box you get Ruby, Node, Go, and Python; mise can install more (Rust, Java, other versions) on demand.

  • Syrus CLI — /usr/local/bin/syrus is already present in the web and

worker containers, so workflow subprocesses and repository scripts can call it without rebuilding the CLI themselves.

  • System packages (apt) — set EXTRA_APT_PACKAGES in .env

(space-separated) and rerun bin/compose-up. They're baked into the worker image — reproducible and cached.

  EXTRA_APT_PACKAGES="imagemagick ffmpeg libmagic-dev"

For anything beyond apt, edit Dockerfile.local (it just extends the base worker image).

Compose workers advertise os:linux automatically. SYRUS_WORKER_CAPABILITIES only accepts the supported OS placement values, so most Compose installs do not need to set it:

SYRUS_WORKER_CAPABILITIES=os:linux

For native macOS workers outside Compose, set the same variable on the worker process, for example os:macos.

Syrus routes non-default pinned workspace work onto capability-specific worker queues such as runs-macos-arm64 and the matching merges-* queues. Distributed graders can use target-specific capability requirements, while orchestration phases stay on the broad compute queues. Docker Compose's default worker consumes the broad Linux queues from config/queue.yml; native Mac workers consume only the macOS capability lanes when they advertise os:macos. Split deployments should run config/queue.compute.yml on compute workers so those queues are consumed without moving chat, polling, or indexing work onto native compute hosts. If a workflow phase requires macOS and no live macOS worker is available, Syrus keeps the workflow queued with a visible queue-capability diagnostic and retries admission instead of creating work that would be stranded. Run enqueue repeats the same check as a backstop if worker capacity disappears between admission and dispatch.

Local source builds use the Docker cache on your machine by default. To also reuse the registry-backed BuildKit cache written by production deploys, provide a GHCR token ($GHCR_TOKEN or ~/.config/syrus/ghcr-token) and run:

SYRUS_DOCKER_REGISTRY_CACHE=1 bin/compose-up

That pulls and updates ghcr.io/tkadauke/syrus:cache. Override the cache tag with SYRUS_DOCKER_CACHE_REF=owner/image:cache when building from a fork or a private registry.

The worker runs unprivileged (uid 1000, no sudo), so a repo's prepare: cannot apt-get install — that's why system packages live at the image layer via EXTRA_APT_PACKAGES.

Configuration

compose.env.example is the template; bin/compose-up copies it to .env and fills the secrets. Notable values:

  • SYRUS_SQLITE=1, SYRUS_DATA_ROOT=/home/rails/.syrus — SQLite local mode.
  • SEARCH_DATABASE_PATH=/home/rails/.syrus-search/search.sqlite3 — dedicated

chat full-text search database.

  • SYRUS_APP_HOST=localhost:3000, SYRUS_ASSUME_SSL=false,

SYRUS_FORCE_SSL=false — plain HTTP locally.

  • SYRUS_TERMINAL_HOST=worker — set by docker-compose.yml on the worker

service so terminal relay sockets advertise an address reachable by the web container through Docker's internal DNS. The value stays blank in .env.

  • SYRUS_BIND=127.0.0.1 — host interface for the web port. Keep the loopback

default for local use. Remote access requires an explicit bind override and should be protected by HTTPS; see TLS.

  • SYRUS_PORT=3000 — host port mapped to the container.
  • SYRUS_PLUGIN_RUNTIME_URL, SYRUS_PLUGIN_RUNTIME_TOKEN — how web and

worker reach the plugin-runtime container. The installer generates the token (and adds both to an existing .env on update).

  • SYRUS_PLUGIN_RUNTIME_IMAGE — the manager image; the installer pins it to

the backend's release.

  • SYRUS_WORKER_CPU_LIMIT — the CPU count used to size worker parallelism when

Docker exposes no cgroup CPU quota. The installer fills it once from the Docker VM's CPU count; an existing nonblank operator value is preserved.

  • SECRET_KEY_BASE, ACTIVE_RECORD_ENCRYPTION_* — generated; keep them

stable across restarts or stored GitHub/agent credentials can't be decrypted. Regenerate a key by hand with openssl rand -hex 32.

Persist and back up

Application data lives in the syrus-data and syrus-search named volumes. Fresh installs bound the prepared-workspace cache and retained Coding Mode checkouts to 10 GB each so these volumes cannot grow without a local policy. Existing stored settings survive upgrades. Inspect the instance defaults and each worker's latest reported effective cache limit under Admin → Retention & Storage; change the instance values under Admin → Settings, or set SYRUS_PREPARED_LAYER_CACHE_GB on an individual worker. Back it up by copying the SQLite files out:

docker compose cp web:/home/rails/.syrus/db ./syrus-db-backup
docker compose cp worker:/home/rails/.syrus-search ./syrus-search-backup

A filesystem snapshot or tar of the volumes works too. Because database state and clone/workspace state are split across volumes, snapshot them together when preserving active runs.

Uninstall

uninstall.sh at the repo root reverses install.sh --docker, plus the desktop-app artifacts when present. Interactive by default: the script prints exactly what it will remove and asks one y/N question. Re-running is always safe — missing artifacts are skipped, and an unreachable Docker daemon skips only the Docker steps.

./uninstall.sh              # interactive — prints the plan, asks once
./uninstall.sh --keep-data  # keep all data; remove app, CLI, containers, images

What it removes:

  • The Compose stack (project syrus) and — unless --keep-data — its

volumes (syrus_syrus-data, syrus_syrus-search), plus any plugin service containers and their volumes (such as Git Mirror's mirrors).

  • Every *syrus-backend image (any registry/tag), ghcr.io/*/syrus-local

dev images, and syrus-plugin-* plugin service images.

  • ~/.syrus/local — **its .env holds the database encryption keys;

removing it together with the data volume destroys the local Syrus data permanently** (skipped by --keep-data).

  • ~/.syrus/credentials (skipped by --keep-data).
  • The syrus CLI: ~/.local/bin/syrus.
  • The Claude Code skill at ~/.claude/skills/syrus.
  • The desktop app and its settings (settings skipped by --keep-data),

where present.

Shared tools are never touched: Docker Desktop, OrbStack, Colima, and Homebrew have their own uninstallers.

FlagEffect
--yesSkip the confirmation prompt.
--keep-dataPreserve ~/.syrus (encryption keys, credentials), the Docker data volumes, and the desktop app settings. Still removes the app, CLI, skill, containers, and images — a later reinstall adopts the kept data.
--jsonMachine-readable NDJSON events on stdout (start, step, log, error, done), the same protocol the installer speaks; implies --yes.

Exit codes: 0 ok (including a declined prompt and "nothing left to remove"), 2 usage, 1 anything else.

Desktop app users don't need the script directly: Syrus → Uninstall Syrus… in the app menu drives the same teardown — see the desktop page.

TLS

Compose doesn't own TLS. For a local install you don't need it (plain HTTP on localhost). To expose it, put Caddy, Traefik, or nginx in front of the web service and terminate HTTPS there. Set SYRUS_BIND=0.0.0.0 only when the proxy must connect through a non-loopback host interface, and do not open the port to an untrusted network: until setup is complete, the first visitor can create the admin account. Set SYRUS_APP_HOST and SYRUS_ASSUME_SSL=true for the public HTTPS origin. If the proxy serves additional hostnames, list them in SYRUS_ALLOWED_HOSTS; the canonical SYRUS_APP_HOST remains allowed even when extras are configured.

Health Check

The Compose web service uses GET /readyz as its container health check. The probe returns not-ready when Rails can boot but Syrus cannot do useful work, for example when no Solid Queue worker has a fresh heartbeat. Use /up only for lightweight process liveness.

Develop from source instead

If you're working *on* Syrus rather than just running it, the bare-metal path (bin/setup then bin/dev) gives faster reloads — see the project README. Compose is the recommended way to run it because it owns the answer to "what else do I need installed?"