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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions