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:
- โ Add a deploy step gated on tags or branches
- โ Deploy via SSH with a pipeline-injected key
- โ Sync files with rsync for non-containerized services
- โ Separate staging and production environments
- โ Require manual approval before production deploys
Table of Contents
- Context / Why This Matters
- Implementation / Core Content
- Practical Examples
- Troubleshooting & Common Pitfalls
- Next Steps / Ops Actions
- 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:
- 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.
- 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:
- ®istry 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
- Document how to undo a bad deploy: rollback-procedures
- Scan pushed images before they reach production: security-scanning
- Consider moving to declarative manifests pulled by the cluster: gitops-pipelines
Sources & Related
External references consulted:
Related knowledge-base articles:
- woodpecker-first-pipeline
- ci-secrets-management
- container-registry-integration
- docker-compose-patterns
- ssh-security-hardening
- caddy-reverse-proxy
Change Log
2026-08-26
- Initial creation by KB writing session.