Skip to content

Add Doco-CD deploy runner for stackctl-based Swarm deployment #534

Description

@wax911

Background

local-stack has standardized on Docker Swarm stacks and stackctl.sh as the canonical deployment interface. The repository should now use Doco-CD as the deployment controller, but Doco-CD must not bypass the existing repository-specific deployment logic.

The critical constraint is that stacks/*.yml are generated artifacts and contain unresolved ${VAR} placeholders. They are not safe to deploy directly. stackctl.sh renders them into .rendered/*.rendered.yml and then runs docker stack deploy.

The hosted machine currently deploys from dev, uses stackctl, and is a single-node Swarm. This issue adds a Doco-CD-compatible deployment runner that preserves that flow.

Goals

  • Add a Doco-CD deployment path for local-stack.
  • Deploy from the dev branch.
  • Support both Doco-CD polling and GitHub webhook triggers.
  • Preserve the current stackctl deployment model.
  • Support the current single-node Swarm host.
  • Keep the design reusable for other projects later.
  • Allow patch/minor dependency updates to deploy immediately after merge.
  • Keep major updates/manual promotions outside the automatic path.

Non-goals

  • Do not replace stackctl.sh in this issue.
  • Do not make Doco-CD deploy unresolved stacks/*.yml directly.
  • Do not introduce Watchtower as the primary deployment system.
  • Do not require GitHub Actions to access the production host.
  • Do not store host credentials in GitHub Actions.

Required architecture

Use Doco-CD as the deployment controller, but deploy a purpose-built runner service that executes the existing canonical flow.

Required flow:

GitHub push to dev
  -> Doco-CD polling or webhook detects the change
  -> Doco-CD deploys/recreates local-stack-deployer
  -> local-stack-deployer fetches AniTrend/local-stack@dev
  -> local-stack-deployer installs stack render dependencies
  -> local-stack-deployer runs stackctl checks
  -> local-stack-deployer runs stackctl deployment
  -> stackctl renders .rendered/*.rendered.yml
  -> stackctl runs docker stack deploy
  -> stackctl reports service status

The runner exists because Doco-CD deliberately should not be asked to run arbitrary shell against the repository, while this repository needs stackctl.sh for safe render/deploy behavior.

Required Doco-CD config

Add root .doco-cd.yml:

name: local-stack-deployer
reference: dev
working_dir: deploy/doco/local-stack-deployer
compose_files:
  - docker-compose.yml

webhook_filter: "^refs/heads/dev$"

remove_orphans: true
prune_images: true
force_recreate: true
force_image_pull: true
timeout: 900
git_depth: 10

reconciliation:
  enabled: true

Implementation notes:

  • reference must be dev.
  • webhook_filter must only accept refs/heads/dev.
  • Polling must also be supported by Doco-CD configuration or documented host-side configuration.
  • force_recreate is intentional so the one-shot runner executes after a deployment-triggering change.
  • timeout should be high enough for image pulls and stack redeploys on a low-resource host.

Required deploy runner files

Add:

deploy/doco/local-stack-deployer/
  docker-compose.yml
  Dockerfile
  entrypoint.sh
  README.md

Required runner behavior

The runner must:

  • Clone or update https://github.com/AniTrend/local-stack.git.
  • Use branch/ref dev by default.
  • Use git fetch --prune origin dev and git reset --hard origin/dev.
  • Install Python render dependencies using tools/requirements.txt.
  • Use flock or equivalent locking so concurrent deploys do not race.
  • Run ./stackctl.sh doctor --fix-network.
  • Run ./stackctl.sh sync before deployment.
  • Deploy using ./stackctl.sh secrets deploy when SOPS mode is enabled.
  • Support fallback/plain env mode via explicit configuration only.
  • Run ./stackctl.sh status after deployment.
  • Exit non-zero on deployment failure.
  • Emit logs to stdout/stderr for Doco-CD to capture.
  • Accept no arbitrary shell command input from webhook payloads.

Required runner environment variables

The runner must support at least:

LOCAL_STACK_REPOSITORY=https://github.com/AniTrend/local-stack.git
LOCAL_STACK_REF=dev
LOCAL_STACK_WORKDIR=/workspace/local-stack
LOCAL_STACK_DEPLOY_MODE=secrets | plain-env | dry-run
LOCAL_STACK_TARGET_STACKS=infrastructure,observability,platform

Default mode should be secrets if the host is using SOPS + age.

Required runner compose baseline

services:
  local-stack-deployer:
    build:
      context: .
      dockerfile: Dockerfile
    image: local/local-stack-deployer:latest
    environment:
      LOCAL_STACK_REPOSITORY: "https://github.com/AniTrend/local-stack.git"
      LOCAL_STACK_REF: "dev"
      LOCAL_STACK_WORKDIR: "/workspace/local-stack"
      LOCAL_STACK_DEPLOY_MODE: "secrets"
      LOCAL_STACK_TARGET_STACKS: "infrastructure,observability,platform"
    volumes:
      - local-stack-workspace:/workspace
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/local-stack/sops/age:/root/.config/sops/age:ro
    restart: "no"

volumes:
  local-stack-workspace:

The Docker socket mount is accepted for this project, but it must be documented as privileged and intentionally scoped to this runner/Doco-CD deployment model.

Required runner entrypoint baseline

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

LOCK_FILE="/tmp/local-stack-deploy.lock"
REPO="${LOCAL_STACK_REPOSITORY:?LOCAL_STACK_REPOSITORY is required}"
REF="${LOCAL_STACK_REF:-dev}"
WORKDIR="${LOCAL_STACK_WORKDIR:-/workspace/local-stack}"
MODE="${LOCAL_STACK_DEPLOY_MODE:-secrets}"
STACKS="${LOCAL_STACK_TARGET_STACKS:-infrastructure,observability,platform}"

exec 9>"$LOCK_FILE"
if ! flock -n 9; then
  echo "Another local-stack deployment is already running; exiting."
  exit 75
fi

if [[ ! -d "$WORKDIR/.git" ]]; then
  git clone --branch "$REF" --depth 10 "$REPO" "$WORKDIR"
fi

cd "$WORKDIR"

git fetch --prune origin "$REF"
git reset --hard "origin/$REF"

python3 -m venv tools/.venv
tools/.venv/bin/python -m pip install --upgrade pip
tools/.venv/bin/python -m pip install -r tools/requirements.txt

./stackctl.sh doctor --fix-network
./stackctl.sh sync

case "$MODE" in
  secrets)
    ./stackctl.sh secrets deploy
    ;;
  plain-env)
    ./stackctl.sh up --no-logs -s "$STACKS"
    ;;
  dry-run)
    ./stackctl.sh up --dry-run --no-logs -s "$STACKS"
    ;;
  *)
    echo "Unknown LOCAL_STACK_DEPLOY_MODE: $MODE" >&2
    exit 2
    ;;
esac

./stackctl.sh status

Important implementation detail:

Do not use git clean -fd unless all plaintext .env files are produced from encrypted .env.enc during the deployment and safely removed afterwards. If a host uses local untracked plaintext .env files, git clean -fd could delete operational configuration. For this project, prefer SOPS deploy mode and document it as the recommended production path.

Doco-CD bootstrap requirement

Add:

doco-cd/
  docker-compose.yml
  .env.example
  README.md

This is a host bootstrap stack for running Doco-CD itself. It should be deployed manually first and not self-managed until the system is proven stable.

Baseline bootstrap compose:

services:
  doco-cd:
    image: ghcr.io/kimdre/doco-cd:<pinned-version>
    container_name: doco-cd
    restart: unless-stopped
    ports:
      - "127.0.0.1:8088:80"
    environment:
      TZ: Africa/Johannesburg
      WEBHOOK_SECRET_FILE: /run/secrets/doco_webhook_secret
      API_SECRET_FILE: /run/secrets/doco_api_secret
    volumes:
      - doco-cd-data:/data
      - ./config:/config:ro
      - /var/run/docker.sock:/var/run/docker.sock
    secrets:
      - doco_webhook_secret
      - doco_api_secret
    networks:
      - doco-private

secrets:
  doco_webhook_secret:
    file: ./secrets/webhook_secret
  doco_api_secret:
    file: ./secrets/api_secret

volumes:
  doco-cd-data:

networks:
  doco-private:

The implementation must pin a real Doco-CD version. Do not use latest.

Polling and webhook support

Both must be supported.

Polling:

  • Must be documented as the safer initial mode because it avoids exposing an inbound webhook endpoint.
  • Recommended interval: 5-15 minutes.
  • Must track dev only.

Webhook:

  • Must use Doco-CD webhook secret.
  • Must restrict to refs/heads/dev using webhook_filter.
  • Must be exposed only through a controlled route, tunnel, or private network.
  • Must not accept arbitrary commands or branch names from the payload.

Security requirements

  • Docker socket access must be documented as privileged.
  • Doco-CD and the runner must not be exposed publicly except for a minimal webhook path if webhook mode is enabled.
  • The SOPS age private key must be mounted read-only.
  • The runner must not print secrets.
  • The runner must not leave plaintext .env files behind in secrets mode.
  • The runner must fail closed on missing SOPS key, missing render dependencies, stack drift, render failure, or failed Swarm deploy.
  • The runner must use a lock to prevent concurrent deployments.

Acceptance criteria

  • .doco-cd.yml exists at the repository root and targets dev.
  • .doco-cd.yml deploys only deploy/doco/local-stack-deployer/docker-compose.yml.
  • Doco-CD supports polling for dev.
  • Doco-CD supports webhook deploys filtered to refs/heads/dev.
  • Doco-CD itself is documented in doco-cd/README.md with pinned image usage.
  • Deploy runner exists under deploy/doco/local-stack-deployer/.
  • Deploy runner uses stackctl.sh, not direct docker stack deploy against unresolved files.
  • Deploy runner supports secrets, plain-env, and dry-run modes.
  • Deploy runner defaults to dev.
  • Deploy runner uses locking to prevent concurrent runs.
  • Deploy runner exits non-zero on failure.
  • Local dry-run mode works without mutating the Swarm.
  • SOPS deploy mode works on a non-production host.
  • After deploy, ./stackctl.sh status is executed.
  • Plaintext .env files are not left behind in SOPS mode.

Test plan

  1. Bootstrap Doco-CD manually on a test host.
  2. Configure polling against AniTrend/local-stack@dev.
  3. Set deploy runner mode to dry-run.
  4. Confirm Doco-CD runs the runner and logs successful render/dry-run output.
  5. Switch deploy runner to secrets mode.
  6. Confirm SOPS age key is available read-only.
  7. Run deployment against a disposable single-node Swarm.
  8. Confirm docker stack services infrastructure, observability, and platform return expected services.
  9. Confirm plaintext .env files are removed after deploy.
  10. Configure webhook mode.
  11. Push a harmless commit to dev and confirm webhook-triggered deployment.
  12. Confirm duplicate/rapid triggers do not race due to locking.

Links to related work

  • Depends on the Dependabot/OpenCode gate issue.
  • Deployment hardening and rollback are covered by a separate runbook issue.
  • Branch protection and auto-merge policy are covered by a separate repository policy issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions