Files
homelab-upgrade/deploy/compose.yaml

218 lines
9.9 KiB
YAML

# Target: /opt/homelab/compose/homelab-upgrade/compose.yaml on VM103, next to update.sh.
# The image is built by Gitea Actions and pulled from the Gitea registry (see docs/DEPLOY.md); TAG comes from .env (update.sh),
# default "latest". Data lives in /srv/appdata/homelab-upgrade/data (uid 1000), manifest backups in .../backups (root, 0700),
# the Proxmox timer's record of the newest vzdump in .../host.
name: homelab-upgrade
x-common: &common
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
restart: unless-stopped
user: "1000:1000"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs: [/tmp]
volumes:
- /srv/appdata/homelab-upgrade/data:/data
services:
# The web app: reads the database, queues runs (by hand and by each workflow's cron schedule) and records decisions;
# executes nothing. No Docker socket, no host port.
server:
<<: *common
command: ["node", "src/main/server.ts"]
environment:
TZ: Europe/Warsaw
FUNCTION_RUNTIME_URL: http://function-runtime:8085
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks: [default, proxy]
labels:
traefik.enable: "true"
traefik.docker.network: "proxy"
traefik.http.routers.homelab-upgrade.rule: 'Host(`upgrade.mariuszpacek.pl`)'
traefik.http.routers.homelab-upgrade.entrypoints: "web-secured"
traefik.http.routers.homelab-upgrade.priority: "100"
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-lan"
traefik.http.routers.homelab-upgrade.tls: "true"
traefik.http.routers.homelab-upgrade.tls.certresolver: "http"
traefik.http.routers.homelab-upgrade.service: "homelab-upgrade"
traefik.http.services.homelab-upgrade.loadbalancer.server.port: "8080"
# No login yet: LAN only.
traefik.http.middlewares.homelab-upgrade-lan.ipwhitelist.sourcerange: "192.168.0.1/24,10.0.0.0/16"
# Tile on dash.mariuszpacek.pl: homepage discovers it through its own Docker connection (my-docker), no edit of its files.
homepage.group: "Infra"
homepage.name: "Upgrade"
homepage.icon: "mdi-update"
homepage.href: "https://upgrade.mariuszpacek.pl"
homepage.description: "Compose app updates"
homepage.server: "my-docker"
homepage.container: "homelab-upgrade-server-1"
# The driver: runs queued workflow runs one step at a time and pushes to the owner's phone when a run waits for them,
# fails, or a real run ends. It finds the running containers by their compose labels (docker ps through the socket proxy
# below, GET only) and asks the registries what is newer. It reads no compose project file and changes nothing itself:
# for apps with real updates on (app_config.apply) it asks the executor after deterministic backup, preflight and diff
# gates. It reads the Proxmox timer's record of the newest vzdump.
driver:
<<: *common
command: ["node", "src/main/driver.ts"]
environment:
TZ: Europe/Warsaw
DOCKER_HOST: tcp://docker-proxy:2375
EXECUTOR_URL: http://executor:8081
HOST_BACKUP_FILE: /host/vzdump.json
# A model profile (Models page) picks the provider: claude or codex.
MODEL_CLAUDE_URL: http://model-claude:8083
MODEL_CODEX_URL: http://model-codex:8084
FUNCTION_RUNTIME_URL: http://function-runtime:8085
# From .env next to this file (never in the repo): HA_URL, HA_TOKEN, HA_NOTIFY.
HA_URL: ${HA_URL:-}
HA_TOKEN: ${HA_TOKEN:-}
HA_NOTIFY: ${HA_NOTIFY:-notify.notify}
volumes:
- /srv/appdata/homelab-upgrade/data:/data
- /srv/appdata/homelab-upgrade/host:/host:ro
networks: [default, docker, executor]
depends_on: [docker-proxy, executor, model-claude, model-codex, function-runtime]
# The executor publishes Compose changes to Git and waits for Arcane to deploy them, one project at a time.
# Explicitly direct projects use the local Compose CLI. Which apps get real updates is set in the app (Apps → Real updates); EXECUTOR_PROJECTS="*" leaves it there,
# a comma-separated list would narrow it. Never homelab-upgrade itself.
# Gates: pinned image, private backup of exactly the planned manifest, expanded diff = the image only.
# Root because the manifests are root:root 600; no capabilities, read-only root, no port, reachable only by the driver.
# /opt/homelab/compose is mounted at the same path so that relative paths in manifests resolve as on the host;
# /var/lib/docker only for the free-space check.
executor:
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
command: ["node", "src/main/executor.ts"]
restart: unless-stopped
user: "0:0"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs: [/tmp]
environment:
TZ: Europe/Warsaw
EXECUTOR_PORT: "8081"
EXECUTOR_PROJECTS: "*"
AGENT_EXEC_RULES_FILE: /app/config/agent-exec.json
DRIVER_HOST: driver
# Provisioned on VM103; webhook tokens and SSH keys stay outside this repository (docs/DEPLOY.md).
GIT_DEPLOYMENTS_FILE: /git-config/deployments.json
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/homelab/compose:/opt/homelab/compose
- /srv/appdata/homelab-upgrade/backups:/backups
- /var/lib/docker:/var/lib/docker:ro
- /srv/appdata/homelab-upgrade/git:/git
- /srv/appdata/homelab-upgrade/git-ssh:/git-ssh:ro
- /srv/appdata/homelab-upgrade/git-config:/git-config:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8081/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Git mode also needs default for outbound SSH to Gitea and HTTPS to Arcane.
networks: [executor, default]
# The Claude model sidecar: unmodified `claude` CLI logged in with owner's account (private /claude volume).
# No Docker socket, no project files, no database. Egress (Anthropic) through default; executor and driver through executor.
model-claude:
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
command: ["node", "src/main/model-claude.ts"]
restart: unless-stopped
user: "1000:1000"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs: [/tmp]
environment:
TZ: Europe/Warsaw
HOME: /claude
CLAUDE_CONFIG_DIR: /claude
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
MODEL_PORT: "8083"
HL_URL: http://executor:8081
DRIVER_HOST: driver
volumes:
- /srv/appdata/homelab-upgrade/claude:/claude
networks: [default, executor]
# The Codex model sidecar: `codex exec --ephemeral` logged in with owner's account (private /codex volume).
# No Docker socket, no project files, no database. Egress (OpenAI) through default; executor and driver through executor.
model-codex:
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
command: ["node", "src/main/model-codex.ts"]
restart: unless-stopped
user: "1000:1000"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs: [/tmp]
environment:
TZ: Europe/Warsaw
HOME: /codex
CODEX_CONFIG_DIR: /codex
CODEX_HOME: /codex
CODEX_BIN: /opt/codex/bin/codex
MODEL_CODEX_PORT: "8084"
HL_URL: http://executor:8081
DRIVER_HOST: driver
volumes:
- /srv/appdata/homelab-upgrade/codex:/codex
networks: [default, executor]
# The isolated function runtime sidecar: runs untrusted workflow functions inside bwrap sandbox namespaces.
# Read-only root, ephemeral execution, no /data, no Docker socket, no network by default.
# bwrap builds its sandbox from an unprivileged user namespace, which Docker's defaults each block: seccomp (unshare),
# AppArmor (making / a slave mount) and masked /proc (a fresh procfs). Tested on VM103, 2026-09-22. The container stays
# non-root with no capabilities; bwrap is the isolation for the function code, and the runtime refuses to run without it.
function-runtime:
image: git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:${TAG:-latest}
command: ["node", "src/main/function-runtime.ts"]
restart: unless-stopped
user: "1000:1000"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true", "seccomp=unconfined", "apparmor=unconfined", "systempaths=unconfined"]
tmpfs: [/tmp]
environment:
TZ: Europe/Warsaw
FUNCTION_RUNTIME_PORT: "8085"
networks: [default]
# The only holder of the Docker socket. It forwards GET on containers (POST stays off), so a client cannot start, stop,
# create or delete anything. Known limit: a container inspect includes that container's environment, so the driver can
# read other containers' variables; this is why the driver is trusted code until the box exists (docs/DESIGN.md).
docker-proxy:
image: tecnativa/docker-socket-proxy:0.3.0@sha256:9e4b9e7517a6b660f2cc903a19b257b1852d5b3344794e3ea334ff00ae677ac2
restart: unless-stopped
environment:
CONTAINERS: "1"
POST: "0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [docker]
# Not read_only: the entrypoint writes haproxy.cfg next to its template in /usr/local/etc/haproxy at every start
# (a read-only root made it restart in a loop on VM103, 2026-09-19). A tmpfs there would hide the template.
tmpfs: [/run]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
networks:
proxy:
name: proxy
external: true
# Between the driver and the socket proxy only: no route out.
docker:
internal: true
# Private driver/executor API; Git mode gives the executor a separate route to Gitea and Arcane.
executor:
internal: true