Deployment Automation - CI-Driven Deploys with SSH, rsync, and Registries

Status: Active
Last Updated: 2026-08-26
Category: CI/CD - Phase 2: Delivery
Prerequisites: woodpecker-first-pipeline, ci-secrets-management, ssh-basics
Time: 3 hours
Tags: woodpecker, deployment, ssh, rsync, registry, environments, approvals

Summary

Turn a passing pipeline into a real deployment: build and push an image or artifact, then deliver it to a server over SSH/rsync or by pulling from your container registry. This article covers deploy steps in Woodpecker, environment separation (staging vs production), and manual approval gates for production.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Table of Contents

  1. Context / Why This Matters
  2. Implementation / Core Content
  3. Practical Examples
  4. Troubleshooting & Common Pitfalls
  5. Next Steps / Ops Actions
  6. Sources & Related

Context / Why This Matters

woodpecker-first-pipeline gets you green checkmarks; it doesn't ship anything. The gap between "CI passes" and "the new version is running" is where most homelab incidents live โ€” hand-run git pull && docker compose up -d on the target host is unrepeatable and unaudited. Automating the delivery step makes every deployment reproducible, attributable to a commit, and rollback-ready (rollback-procedures).

The stack here assumes Forgejo + Woodpecker on Linux servers as described in forgejo-installation and woodpecker-installation, with images published per container-registry-integration.

Implementation / Core Content

Deployment models

Pick one model per service and stick to it:

  1. Registry pull (recommended): CI pushes an image to your registry; the target host pulls and recreates the container. Only the image moves โ€” the host needs credentials for the registry.
  2. SSH + rsync: CI copies built artifacts (binaries, static sites, compose files) directly to the host. Simple, no registry required.

Model 1: push image, pull on the host

steps:
  publish:
    image: plugins/docker
    when:
      event: [tag]
    settings:
      repo: registry.fogserv.cloud/homelab/myapp
      tags:
        - "${CI_COMMIT_TAG}"
        - "latest"
      username:
        from_secret: registry_user
      password:
        from_secret: registry_pass

  deploy:
    image: alpine:3.20
    when:
      event: [tag]
    environment:
      SSH_KEY:
        from_secret: deploy_ssh_key
    commands:
      - apk add --no-cache openssh-client
      - mkdir -p ~/.ssh && chmod 700 ~/.ssh
      - echo "$$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
      - ssh-keyscan -H app01.internal >> ~/.ssh/known_hosts
      - |
        ssh deploy@app01.internal '
          cd /srv/myapp &&
          docker compose pull &&
          docker compose up -d --remove-orphans
        '

The compose file on the host references the image tag ${MYAPP_VERSION:-latest} so pull fetches the newly pushed version. Prefer deploying explicit tags (not latest) โ€” see rollback-procedures.

Model 2: SSH + rsync

steps:
  build-site:
    image: node:22
    commands:
      - npm ci && npm run build

  deploy:
    image: alpine:3.20
    when:
      branch: main
      event: [push]
    environment:
      SSH_KEY:
        from_secret: deploy_ssh_key
    commands:
      - apk add --nocache openssh-client rsync >/dev/null || apk add --no-cache openssh-client rsync
      - mkdir -p ~/.ssh && chmod 700 ~/.ssh
      - echo "$$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
      - ssh-keyscan -H web01.internal >> ~/.ssh/known_hosts
      - rsync -az --delete --checksum dist/ deploy@web01.internal:/var/www/myapp/
      - ssh deploy@web01.internal 'sudo systemctl reload caddy'

Key flags: -a preserve attributes, -z compress, --delete mirror removals, --checksum catch same-size changes. For a first sync drop --delete until you trust the mapping.

Preparing the SSH key

Generate a dedicated, restricted deploy key (never reuse your admin key):

ssh-keygen -t ed25519 -f deploy_myapp -N "" -C "ci-deploy-myapp"
# On each target host:
mkdir -p ~deploy/.ssh && cat deploy_myapp.pub >> ~deploy/.ssh/authorized_keys

Restrict what the key can do in authorized_keys if the deploy user has broader rights:

restrict,command="/usr/local/bin/deploy-myapp.sh" ssh-ed25519 AAAA... ci-deploy-myapp

Store the private key as a Woodpecker secret named deploy_ssh_key, limited to the repository and (ideally) to the deploy step image. Full secret-handling guidance: ci-secrets-management. Harden the target hosts themselves per ssh-security-hardening.

Environment separation: staging vs production

Use two secrets sets and two triggers rather than two pipelines:

  deploy-staging:
    image: alpine:3.20
    when:
      branch: main
    environment:
      SSH_KEY:
        from_secret: staging_deploy_key
    commands: *deploy_commands   # YAML anchor shared with prod step

  deploy-production:
    image: alpine:3.20
    when:
      event: [tag]
    environment:
      SSH_KEY:
        from_secret: prod_deploy_key
    depends_on: [deploy-staging]

Staging follows main; production fires only on semver tags. Both use identical commands, different keys and hosts โ€” no drift.

Manual approval gates

Woodpecker supports blocking steps that wait for UI approval. Gate the production deploy behind one:

  approve-production:
    image: alpine:3.20
    when:
      event: [tag]
    commands:
      - echo "Awaiting approval to deploy ${CI_COMMIT_TAG} to production"

  deploy-production:
    image: alpine:3.20
    when:
      event: [tag]
    depends_on: [approve-production]
    # ... ssh commands ...

Configure the review/approval requirement for the workflow in .woodpecker.yml (skip_clone not needed; use when: + the repo's "Require approval for" setting in Woodpecker project settings โ€” set it to Tag events or All events for restricted repos). Only users with write access can approve, giving you a human checkpoint before production traffic shifts.

Practical Examples

Example: full tagged-release flow for a compose-based service

when:
  event: [push, tag]

variables:
  - &registry registry.fogserv.cloud/homelab

steps:
  test:
    image: golang:1.23
    commands:
      - go vet ./... && go test ./...

  publish:
    image: plugins/docker
    when:
      event: [tag]
    settings:
      repo: *registry/myapp
      tags: ["${CI_COMMIT_TAG}", "stable"]
      username:
        from_secret: registry_user
      password:
        from_secret: registry_pass

  deploy-prod:
    image: alpine:3.20
    when:
      event: [tag]
    depends_on: [publish]
    environment:
      SSH_KEY:
        from_secret: prod_deploy_key
    commands:
      - apk add --no-cache openssh-client
      # key setup omitted (see above)
      - ssh deploy@app01.internal "cd /srv/myapp && MYAPP_VERSION=${CI_COMMIT_TAG} docker compose up -d"
      - ssh deploy@app01.internal "curl -fsS http://localhost:8080/healthz"

Expected behavior: push โ†’ tests only. Tag v1.4.2 โ†’ tests, image v1.4.2 + stable pushed, host pulls exactly v1.4.2, health endpoint verified before the workflow goes green.

Troubleshooting & Common Pitfalls

Problem Cause Fix
Permission denied (publickey) Key secret contains literal \n escapes or wrong key type Paste PEM/OpenSSH body verbatim into secret; ensure matching key pair and authorized_keys entry
Host key verification failed Target host missing from known_hosts Run ssh-keyscan -H host >> ~/.ssh/known_hosts inside the step before connecting
Host pulls old image Compose file uses cached latest or no pull step Always docker compose pull (or pass explicit version var) before up -d
rsync deletes wrong files on host --delete against a directory containing non-CI state Scope to a dedicated release dir; add --exclude for runtime data
Deploy step skipped silently when: conditions don't match event/branch Check workflow's trigger badge in Woodpecker UI
Secret empty in script Missing $$ escaping so shell sees nothing Use $$SECRET_NAME inside commands; see ci-secrets-management

Next Steps / Ops Actions

Sources & Related

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse