A reusable GitHub Action to manage service-level Docker Continuous Delivery.
This action builds and signs Docker images, then deploys running services remotely via Docker Compose over SSH, using image digests for safe and reproducible rollouts.
This action encapsulates a complete Docker-based CD workflow:
- Build Docker images using Buildx with cache support
- Push images to a registry (GHCR)
- Sign images with Cosign for supply-chain security
- Update running services on a remote server via SSH
- Deploy with image digests, not mutable tags
It is designed to be reused across multiple services and repositories.
-
🔁 Digest-based deployment Ensures reproducible and rollback-friendly deployments.
-
🔐 Image signing with Cosign Adds a security layer to published images.
-
🚀 Remote deployment via SSH + Docker Compose No Kubernetes required. Simple, explicit, and transparent.
-
🧪 Dry-run mode Run the entire pipeline without pushing or deploying artifacts.
-
📦 Service-oriented design One action, many services.
-
🗂 Monorepo support Point the build at any subdirectory with
contextanddockerfileinputs.
This action assumes a simple and explicit Docker Compose setup. To enable digest-based deployment, your project must follow the structure below.
Each service image must reference a digest variable defined in .env.
services:
portfolio:
image: ghcr.io/your-org/portfolio@${PORTFOLIO_IMAGE_DIGEST}
restart: unless-stopped
ports:
- "3000:3000"Key points:
- Image tags are not used directly
- Each service reads its image digest from
.env - Service name must match the
service-nameinput
For each service, define an image digest variable following this convention:
PORTFOLIO_IMAGE_DIGEST=sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxNaming rules:
- Based on
service-name - Converted to UPPER_SNAKE_CASE
-and spaces are replaced with_- Suffix
_IMAGE_DIGESTis mandatory
Examples:
- service-name:
portfolio→PORTFOLIO_IMAGE_DIGEST - service-name:
user-api→USER_API_IMAGE_DIGEST
During deployment, the action:
- Builds and pushes a new Docker image
- Resolves the exact image digest
- Updates the corresponding variable in
.env - Runs:
docker compose up -d --no-deps <service-name>This ensures:
- Only the target service is restarted
- The deployment is deterministic
- Rollbacks are as simple as reverting the digest
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy service
uses: your-org/docker-service-cd@v1
with:
registry: ghcr.io/your-org/your-service
service-name: portfolio
ssh-host: ${{ secrets.SSH_HOST }}
ssh-user: ${{ secrets.SSH_USER }}
ssh-key: ${{ secrets.SSH_KEY }}jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: your-org/docker-service-cd@v1
with:
context: frontend # uses frontend/Dockerfile by default
registry: ghcr.io/your-org/repo-frontend
service-name: frontend
ssh-host: ${{ secrets.SSH_HOST }}
ssh-user: ${{ secrets.SSH_USER }}
ssh-key: ${{ secrets.SSH_KEY }}
build-backend:
runs-on: ubuntu-latest
steps:
- uses: your-org/docker-service-cd@v1
with:
context: backend # uses backend/Dockerfile by default
registry: ghcr.io/your-org/repo-backend
service-name: backend
ssh-host: ${{ secrets.SSH_HOST }}
ssh-user: ${{ secrets.SSH_USER }}
ssh-key: ${{ secrets.SSH_KEY }}with:
context: backend
dockerfile: backend/Dockerfile.prod # explicit override
registry: ghcr.io/your-org/repo-backend
service-name: backend
...| Name | Required | Default | Description |
|---|---|---|---|
skip-checkout |
❌ | false |
Skip the repository checkout step |
dry-run |
❌ | false |
Run pipeline without pushing or deploying |
registry |
✅* | — | Image name (e.g. ghcr.io/user/repo) |
service-name |
✅* | — | Docker Compose service name |
ssh-host |
✅* | — | SSH host for deployment |
ssh-user |
✅* | — | SSH username |
ssh-key |
✅* | — | Private SSH key |
ssh-port |
❌ | 22 |
SSH port |
context |
❌ | . |
Docker build context path (e.g. frontend, backend) |
dockerfile |
❌ | — | Path to Dockerfile. Defaults to {context}/Dockerfile when not set. |
✅* Required when dry-run is not true. In dry-run mode these inputs may be omitted.
You can safely validate the pipeline logic without modifying any remote state:
with:
dry-run: "true"In dry-run mode:
- Images are built but not pushed
- Images are not signed
- Deployment over SSH is skipped
This is useful for testing PRs or validating changes.
On the remote server, the action:
- Converts
service-nameinto an environment-safe variable (e.g.portfolio-api→PORTFOLIO_API_IMAGE_DIGEST) - Updates the
.envfile with the new image digest - Runs:
docker compose up -d --no-deps <service>This allows:
- Minimal restarts
- Service-scoped deployments
- Easy rollback by reverting the digest