Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .github/workflows/docker-daemon.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Publish daemon image

# Builds + pushes the headless ARC daemon image to GHCR.
# - Tags prefixed `v*` → stable + semver-tagged images
# - Manual runs → push a debug tag for trying PRs before cutting a release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Extra tag to publish (in addition to sha-<short>)"
required: false
default: ""

permissions:
contents: read
packages: write
id-token: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/arc-daemon

jobs:
build-and-push:
name: Build and push (${{ matrix.platform }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# arm64 runs via QEMU on ubuntu-latest — slower, but avoids
# splitting the pipeline across runner pools.
platform:
- linux/amd64
- linux/arm64
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute platform tag fragment
id: platform
run: |
platform="${{ matrix.platform }}"
echo "pair=${platform##*/}" >> $GITHUB_OUTPUT

- name: Build and push per-platform image
id: build
uses: docker/build-push-action@v6
with:
context: .
file: packages/daemon/Dockerfile
platforms: ${{ matrix.platform }}
push: true
provenance: false
outputs: |
type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true

- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"

- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ steps.platform.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

merge:
name: Publish manifest list
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }}
type=sha,format=short

- name: Create manifest list
working-directory: /tmp/digests
run: |
tags_args=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
digest_refs=$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
docker buildx imagetools create $tags_args $digest_refs

- name: Inspect image
run: |
tag=$(jq -r '.tags[0]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
docker buildx imagetools inspect "$tag"
61 changes: 61 additions & 0 deletions packages/daemon/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Keep the Docker build context small. The Dockerfile lives under
# packages/daemon/ but `docker build` is invoked from the repo root so
# that the monorepo is visible — these ignores run against the root.
#
# What we DO need: package.json files, pnpm workspace manifests,
# and source under packages/{core,client,daemon}/.

# Dependencies + build output (rebuilt inside the image)
**/node_modules
**/dist
**/.turbo

# Tests — not needed for a production image
**/tests
**/*.test.ts
**/*.test.tsx

# VCS
.git
.gitignore

# Editor + OS
.idea
.vscode
.DS_Store
Thumbs.db

# Local env + secrets
.env
.env.*
*.local

# Coverage + reports
coverage
.nyc_output

# Logs
*.log
npm-debug.log*
pnpm-debug.log*

# Landing site + docs — not part of the daemon image
site
user-docs

# Other packages not used by the daemon
packages/cli
packages/dashboard
packages/mcp
packages/relay
packages/adapter-*

# Dev-only files at the root
Dockerfile
nginx.conf
docker-compose.yml
.github

# Don't pull the daemon's own compose + docs into the image
packages/daemon/docker-compose.yml
packages/daemon/DOCKER.md
134 changes: 134 additions & 0 deletions packages/daemon/DOCKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Running `arc-daemon` in Docker

The ARC daemon ships as a container image at
`ghcr.io/axiom-labs/arc-daemon`. It's intended for headless hosts
(workstations, home servers, small teams) where a long-running daemon
serves one or more ARC clients over the binary-mux WebSocket protocol.

The CLI itself — `arc ...` commands, TUI, chat, etc. — is **not**
included. Install it separately from npm on your client machines.

## Quick start

```bash
docker run -d \
--name arc-daemon \
--restart unless-stopped \
-p 127.0.0.1:7272:7272 \
-v arc-state:/home/arc/.arc \
ghcr.io/axiom-labs/arc-daemon:latest
```

Then from your host:

```bash
curl http://127.0.0.1:7272/health
# → {"ok":true,"version":"...","protocol":1, ...}
```

Or via `docker compose` — an example lives in
[`docker-compose.yml`](./docker-compose.yml) alongside this file:

```bash
cd packages/daemon
docker compose up -d
```

## Port mapping

The daemon listens on port **7272** inside the container. The default
is unchanged from a local (`arc daemon start`) install so tooling
keeps working.

| Flag | Effect |
| --- | --- |
| `-p 127.0.0.1:7272:7272` | Loopback only (default, safest) |
| `-p 7272:7272` | All interfaces — see security note below |
| `-p 1.2.3.4:7272:7272` | Bind to a specific host NIC |

Override the in-container port with `ARC_PORT=<n>` if you need to
avoid a collision inside a pod/network (the `HEALTHCHECK` picks it up).

## Volume semantics

State lives under `/home/arc/.arc` inside the container — profiles,
the SQLite DB (`arc.db`), auth keypair, and the daemon log.
Persist it with either a named volume (preferred) or a bind mount:

```bash
# Named volume — Docker-managed, survives container replacement.
-v arc-state:/home/arc/.arc

# Bind mount — maps onto the host filesystem. The directory must be
# owned by uid 1000 (the `arc` user inside the image).
-v /srv/arc:/home/arc/.arc
```

If you bind-mount and see permission errors, `chown -R 1000:1000 /srv/arc`.

## Security notes

* **Default bind is 0.0.0.0:7272 inside the container.** This is safe
when the host-side `-p` flag maps to `127.0.0.1` (as in the quick
start). The daemon itself only accepts connections whose HTTP `Host`
header resolves to a loopback address.
* **Exposing to a LAN requires a reverse proxy.** The daemon's pairing
flow is designed for trusted networks. If you want remote clients,
terminate TLS and mTLS (or an OIDC proxy) *in front* of the
container — don't publish port 7272 to the public internet.
* **Non-root.** The process runs as uid 1000 (`arc`). All capabilities
are dropped in the example compose file and `no-new-privileges` is
set.
* **Secrets.** The daemon generates a long-lived keypair on first
start and stores it inside the state volume (`auth.json`). Back this
up if you want seamless re-issuance after a volume loss.

## Upgrading

Because state lives in a named volume, upgrading is just:

```bash
docker pull ghcr.io/axiom-labs/arc-daemon:latest
docker rm -f arc-daemon
docker run -d \
--name arc-daemon \
--restart unless-stopped \
-p 127.0.0.1:7272:7272 \
-v arc-state:/home/arc/.arc \
ghcr.io/axiom-labs/arc-daemon:latest
```

The bundled compose stack includes a (disabled-by-default)
[watchtower](https://containrrr.dev/watchtower/) sidecar that polls
GHCR hourly and auto-rolls new stable tags. Enable it with:

```bash
docker compose --profile auto-update up -d
```

## Tags

| Tag | Cadence |
| --- | --- |
| `latest` | Latest stable release |
| `1`, `1.0`, `1.0.0` | Major / minor / patch pins |
| `sha-<7>` | Every CI build (no promotion) |

Prereleases (`-alpha`, `-beta`, `-rc`) do **not** update `latest` —
pin to the explicit version if you want to track them.

## Building locally

```bash
# From the repo root. The daemon Dockerfile needs the monorepo context.
docker build -f packages/daemon/Dockerfile -t arc-daemon:dev .

# Smoke test
docker run --rm -d \
--name arc-dt \
-p 17272:7272 \
-v /tmp/arc-docker-test:/home/arc/.arc \
arc-daemon:dev
sleep 3 && curl -s http://127.0.0.1:17272/health
docker stop arc-dt
```
Loading
Loading