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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Thumbs.db
.env.local
.env.*.local

# Local Compose customisation (host-specific, not part of the project)
docker-compose.override.yml
docker-compose.override.yaml

# State files (local to each machine)
.claude/ralph-loop.local.md

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,24 @@ TZ=Australia/Perth

# Name of the account that runs Codeman and all local CLI sessions. Changing
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=opencode
CODEMAN_RUNTIME_USER=codeman

# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman

# Optional. Absolute host path of this Codeman checkout, mounted at
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
# start script detects it from the compose file's own location, so it only needs
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
# at a directory that is not a git checkout and in-app updates are unavailable.
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
# CODEMAN_REPO_PATH=/mnt/user/appdata/codeman/app

# Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a
# container-only path such as /home/opencode/codeman-cases.
CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases
# container-only path such as /home/codeman/codeman-cases.
CODEMAN_CASES_PATH=/mnt/user/appdata/codeman/codeman-cases

# Required. Network bind address, host port, and local image tag.
CODEMAN_HOST=0.0.0.0
Expand Down
52 changes: 48 additions & 4 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY

On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.

Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `codeman`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.

To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.

Expand All @@ -38,6 +38,50 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).

## Local customisation

Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.

`Start-Codeman.sh` names the Compose file explicitly, which disables Compose's automatic discovery of the override file, so the script adds it back when one is present and prints the file it used. Running `docker compose` from this folder without any `-f` option finds it automatically. When passing `-f docker/docker-compose.yaml` from the repository root, add `-f docker/docker-compose.override.yml` as well, or the override is silently ignored.

An override file adds to and replaces individual settings. It cannot delete a key from `docker-compose.yaml`, and Compose concatenates rather than replaces `ports`, so removing a published port still requires editing `docker-compose.yaml`. The example below replaces the restart policy and adds a mount, leaving every other setting in place:

```yaml
services:
codeman:
restart: always
volumes:
- /srv/projects:/srv/projects
```

### Reverse-proxy host allowlist

Codeman rejects any request whose `Host` header is not on its own allowlist - a
DNS-rebinding guard, not a Compose or Docker concern. Loopback, any IP literal,
the configured `--host`, and a few tunnel-provider suffixes are allowed by
default; a reverse-proxied domain is not, and is rejected with
`403 Forbidden: host not allowed` before the request reaches any handler.

Add the domain with `CODEMAN_ALLOWED_HOSTS` in `.env`:

```sh
CODEMAN_ALLOWED_HOSTS='codeman.example.com,.internal.example.com'
```

`docker-compose.yaml` does not forward this variable into the container - it
only passes through the environment keys it explicitly lists, and this is not
one of them. Forward it yourself in `docker-compose.override.yml`:

```yaml
services:
codeman:
environment:
CODEMAN_ALLOWED_HOSTS: ${CODEMAN_ALLOWED_HOSTS}
```

See the application's own `docs/wiki/Remote-Access.md` for the full allowlist
format and the tunnel providers it accepts by default.

## Application data storage

The default configuration uses a host-folder bind mount:
Expand All @@ -49,7 +93,7 @@ volumes:
target: /home/${CODEMAN_RUNTIME_USER}
```

Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`.
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`.

`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.

Expand All @@ -60,7 +104,7 @@ Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:

```sh
chown -R 99:100 /mnt/user/appdata/Coding/codeman
chown -R 99:100 /mnt/user/appdata/codeman
```

Replace `99:100` and the path with the values from your `.env` file.
Expand All @@ -69,7 +113,7 @@ Do not replace this bind mount with a Docker-managed named volume when Docker ca

## Static macvlan networking

The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service:
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section from `docker-compose.yaml` and add the following to the `codeman` service. The service and network additions can instead be placed in `docker-compose.override.yml`, but the `ports:` removal cannot, as described under [Local customisation](#local-customisation):

```yaml
mac_address: ${CODEMAN_MAC_ADDRESS}
Expand Down
133 changes: 131 additions & 2 deletions docker/Start-Codeman.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,34 @@ if [[ ! -f "$env_file" ]]; then
exit 1
fi

compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
# Naming a Compose file explicitly disables Compose's automatic discovery of
# the override file, so it has to be added back by hand. Without this, local
# customisation in docker-compose.override.yml is silently ignored. The
# candidates are checked in Compose's own precedence order - measured on
# Compose v5.5.0 with both present: it uses `.yml` and ignores `.yaml`.
override_yml="$script_dir/docker-compose.override.yml"
override_yaml="$script_dir/docker-compose.override.yaml"
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
"$override_yml" "$override_yaml" >&2
fi
compose_files=(-f "$compose_file")
for override_file in "$override_yml" "$override_yaml"; do
if [[ -f "$override_file" ]]; then
compose_files+=(-f "$override_file")
printf 'Using Compose override file: %s\n' "$override_file"
break
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
cases_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
Expand All @@ -36,6 +59,26 @@ if [[ ! -d "$appdata_path" ]]; then
mkdir -p -- "$appdata_path"
fi

if [[ -z "$cases_path" ]]; then
printf 'Error: CODEMAN_CASES_PATH is not set in %s\n' "$env_file" >&2
exit 1
fi

# Pre-creating this here, exactly like CODEMAN_APPDATA_PATH above, means Compose
# never has to materialise a missing bind source itself - which it does as
# root:root - so the in-container entrypoint's chown never has to run for this
# path at all. Unlike appdata, an EXISTING cases directory is left exactly as
# it is: the README explicitly allows pointing this at a normal projects
# directory the host account already owns, so no ownership check happens here.
if [[ ! -d "$cases_path" ]]; then
if [[ "$EUID" == '0' ]]; then
printf 'Error: Refusing to create CODEMAN_CASES_PATH as root: %s\n' "$cases_path" >&2
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$cases_path"
fi

if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
:
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
Expand Down Expand Up @@ -92,6 +135,31 @@ if [[ ! -d "$repo_path/.git" ]]; then
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
fi

# Reads HEAD without requiring a `git` binary on the host — this script
# otherwise checks the checkout only by testing for `.git` as a directory, and
# resolving refs by hand keeps that the same "no host git needed" guarantee.
# ⚠️ A worktree checkout has `.git` as a FILE (`gitdir: <path>`), not a
# directory, so this returns nothing there and the volume-refresh check below
# silently no-ops — consistent with the `-d .git` test used everywhere else in
# this script, not a special case, but worth knowing if a worktree checkout
# stops picking up a stale-volume refresh it should have caught.
git_head_commit() {
local git_dir="$1/.git" head_ref ref_path
[[ -d "$git_dir" ]] || return 1
head_ref=$(cat -- "$git_dir/HEAD" 2>/dev/null) || return 1
if [[ "$head_ref" == ref:* ]]; then
ref_path="${head_ref#ref: }"
if [[ -f "$git_dir/$ref_path" ]]; then
cat -- "$git_dir/$ref_path"
else
# Packed after a `git gc`; the loose ref file above is gone.
awk -v ref="$ref_path" '$2 == ref { print $1; exit }' "$git_dir/packed-refs" 2>/dev/null
fi
else
printf '%s' "$head_ref"
fi
}

# Record what the container is about to be built and created FROM. The in-app
# updater compares these against the release it wants to apply: a release that
# changes either file cannot be applied by the container restarting itself (a
Expand Down Expand Up @@ -126,4 +194,65 @@ else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi

exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
# behind old volume content until something clears it. The in-app self-updater
# never hits this — it rebuilds INSIDE the running container, into the very
# volume already in use — but a `docker compose build` triggered from outside
# it (this script, after a `git pull`) does: the container comes back up
# looking unchanged. Detect that here and clear just the affected volume(s) so
# `--build` below actually takes effect. Best-effort: with no sha256 tool this
# quietly does nothing, same as the environment-gate block above.
if [[ -n "$dockerfile_sha" ]]; then
repo_head=$(git_head_commit "$repo_path" || true)
lockfile_sha=$(sha256_of "$repo_path/package-lock.json" 2>/dev/null || true)
source_state_file="$state_dir/docker-build-source.json"
prev_head=''
prev_lockfile_sha=''
if [[ -f "$source_state_file" ]]; then
prev_head=$(sed -n 's/.*"headCommit": *"\([^"]*\)".*/\1/p' "$source_state_file")
prev_lockfile_sha=$(sed -n 's/.*"lockfileSha256": *"\([^"]*\)".*/\1/p' "$source_state_file")
fi

volumes_to_refresh=()
[[ -n "$repo_head" && "$repo_head" != "$prev_head" ]] && volumes_to_refresh+=('codeman-dist')
[[ -n "$lockfile_sha" && "$lockfile_sha" != "$prev_lockfile_sha" ]] && volumes_to_refresh+=('codeman-node-modules')

if [[ ${#volumes_to_refresh[@]} -gt 0 ]]; then
# Runs even on this script's very first invocation against an EXISTING
# deployment, deliberately: that deployment's volumes may already be
# stale (there was no earlier version of this check to have caught it),
# and clearing an already-empty or nonexistent volume is a harmless
# no-op, so there is no fresh-install case this needs to avoid.
printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_refresh[*]}"
"${compose_command[@]}" down
# `com.docker.compose.volume` is the volume KEY, not a project-qualified
# name - a second stack on the same host (a beta instance started with a
# different COMPOSE_PROJECT_NAME, say) that also declares a volume keyed
# `codeman-dist` shares that label, and `head -n1` would pick whichever
# the daemon happens to list first. Scope the lookup to THIS stack's own
# resolved project name so it can only ever match this stack's volume.
project_name=$(
"${compose_command[@]}" config --format json 2>/dev/null |
sed -n 's/^ "name": "\(.*\)",\{0,1\}$/\1/p' | head -n1
)
for key in "${volumes_to_refresh[@]}"; do
volume_name=$(
docker volume ls -q \
--filter "label=com.docker.compose.volume=$key" \
--filter "label=com.docker.compose.project=$project_name" |
head -n1
)
[[ -n "$volume_name" ]] && docker volume rm -- "$volume_name"
done
fi

printf '{\n "headCommit": "%s",\n "lockfileSha256": "%s"\n}\n' \
"$repo_head" "$lockfile_sha" >"$source_state_file.tmp"
mv -- "$source_state_file.tmp" "$source_state_file"
if [[ "$EUID" == '0' ]]; then
chown -- "$PUID:$PGID" "$source_state_file"
fi
fi

exec "${compose_command[@]}" up --build -d
7 changes: 7 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# The entrypoint corrects bind-mount ownership as root before dropping to
# PUID:PGID. Everything not listed here remains dropped by cap_drop above.
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
healthcheck:
test:
- CMD-SHELL
Expand Down
Loading