Reusable GitHub Actions template for automated GitOps deployments. Polls GHCR for new image tags, creates PRs to update a .env file (consumed by docker-compose.yaml), and deploys on merge.
See the flow diagram in the root README.
- Trigger (
ci-pr-trigger.yaml) — Runs on schedule, queries GHCR for the latest semver image tags using the custom composite action - PR Creation (
ci-create-release-pr.yaml) — Compares discovered tags with current.envfile. Creates a PR only if versions differ - Deployment — Triggers on push to the deploy branch when files in the project path change. Connects to the server and runs
deployment.sh
Three deployment workflow variants are included. Pick the one that matches your infrastructure:
| Strategy | Workflow | Best for | Networking |
|---|---|---|---|
| EC2 / AWS | ci-deployment.yaml |
AWS infrastructure | AWS credentials + SSM/Instance Connect |
| SSH | ci-deployment-ssh.yaml |
Home servers, VPS, any Linux | SSH key + reachable host |
| Self-Hosted Runner | ci-deployment-self-hosted.yaml |
Home servers, on-prem | None (runner runs on the server) |
The SSH variant uses rsync + ssh to copy files and run deployment.sh on any reachable Linux machine. Several ways to make a home server reachable:
flowchart LR
GH["GitHub Actions\nRunner"] --> decision{"How to reach\nhome server?"}
decision --> A["Static IP /\nPort Forward"]
decision --> B["Tailscale"]
decision --> C["Cloudflare\nTunnel"]
decision --> D["Self-Hosted\nRunner"]
A --> SSH["SSH + rsync\n(ci-deployment-ssh.yaml)"]
B --> SSH
C --> SSH
D --> LOCAL["Local execution\n(ci-deployment-self-hosted.yaml)"]
style B stroke:#4ade80,stroke-width:2px
style D stroke:#4ade80,stroke-width:2px
Option A: Tailscale (recommended) — No port forwarding, no static IP needed. The GitHub Actions runner joins your Tailscale network during the workflow and SSHs to your server via its Tailscale hostname. Uncomment the Tailscale step in ci-deployment-ssh.yaml and add a TAILSCALE_AUTHKEY secret.
Option B: Static IP / DDNS + port forwarding — Forward port 22 (or a custom port) on your router to your server. Use a DDNS service (DuckDNS, Cloudflare, etc.) if your IP changes. Set DEPLOY_HOST to the domain/IP.
Option C: Cloudflare Tunnel — Install cloudflared on your server to create a tunnel. The GitHub Actions runner uses cloudflared access ssh to connect without port forwarding.
The runner runs directly on your server — no SSH, no tunnels, no port forwarding. The deployment job checks out the repo and runs deployment.sh locally.
Setup:
- Install the GitHub Actions runner on your server
- Label it (e.g.,
homeserver) - Replace
{{RUNNER_LABEL}}inci-deployment-self-hosted.yaml - Delete the other deployment workflow files
cp -r templates/gitops-ci/.github /path/to/your/repo/
cp templates/gitops-ci/deployment.sh /path/to/your/repo/projects/<name>/
cp templates/gitops-ci/docker-compose.yaml /path/to/your/repo/projects/<name>/
cp templates/gitops-ci/.env /path/to/your/repo/projects/<name>/Then delete the deployment variants you don't need — keep only one of the three ci-deployment*.yaml files.
Search for {{...}} and replace. Which placeholders you need depends on the deployment strategy:
Common (all strategies):
| Placeholder | Description | Example |
|---|---|---|
{{ORG}} |
GitHub org name | AxleResearch |
{{BACKEND_IMAGE}} |
Backend image name | my-app-backend |
{{FRONTEND_IMAGE}} |
Frontend image name | my-app-frontend |
{{DEPLOY_BRANCH}} |
Branch that triggers deployment | production |
{{PROJECT_PATH}} |
Path to project in the repo | projects/my-app |
{{REMOTE_PATH}} |
Path on the target server | /opt/my-app |
EC2 only:
| Placeholder | Description | Example |
|---|---|---|
{{AWS_REGION}} |
AWS region | us-east-1 |
{{INSTANCE_ID}} |
EC2 instance ID | i-0abc123def456 |
SSH only:
| Placeholder | Description | Example |
|---|---|---|
{{REMOTE_USER}} |
SSH username | deploy |
Self-hosted runner only:
| Placeholder | Description | Example |
|---|---|---|
{{RUNNER_LABEL}} |
Self-hosted runner label | homeserver |
| Secret | Strategy | Required | Description |
|---|---|---|---|
AWS_ACCESS_KEY_ID |
EC2 | Yes | AWS credentials |
AWS_SECRET_ACCESS_KEY |
EC2 | Yes | AWS credentials |
DEPLOY_SSH_KEY |
SSH | Yes | Private SSH key for the server |
DEPLOY_HOST |
SSH | Yes | Server address (IP, domain, or Tailscale hostname) |
DEPLOY_HOST_KEY |
SSH | Recommended | Output of ssh-keyscan <host> |
TAILSCALE_AUTHKEY |
SSH + Tailscale | If using Tailscale | Tailscale auth key |
SLACK_WEBHOOK_URL |
All | No | Slack webhook for notifications |
.github/
actions/
ghcr-latest-tag/
action.yaml # Custom action: query GHCR for latest semver tag
workflows/
ci-pr-trigger.yaml # Cron job polling for new images
ci-create-release-pr.yaml # Reusable workflow: compare & create PR
ci-deployment.yaml # Deploy variant: EC2 / AWS
ci-deployment-ssh.yaml # Deploy variant: SSH (home server / VPS)
ci-deployment-self-hosted.yaml # Deploy variant: self-hosted runner
projects/<name>/
.env # Image tags (BACKEND_TAG / FRONTEND_TAG)
docker-compose.yaml # Compose file (references tags from .env)
deployment.sh # Deployment script
Remove the frontend-related steps from ci-pr-trigger.yaml and ci-create-release-pr.yaml. Remove the frontend service from docker-compose.yaml.
Call ci-create-release-pr.yaml multiple times with different baseBranch and envPath inputs:
deploy-staging:
uses: ./.github/workflows/ci-create-release-pr.yaml
with:
baseBranch: staging
envPath: projects/my-app/staging/.env
deploy-production:
uses: ./.github/workflows/ci-create-release-pr.yaml
with:
baseBranch: production
envPath: projects/my-app/production/.envAdjust the cron expression in ci-pr-trigger.yaml. Consider the cleanup job — higher frequency means more runs to clean up.
| Interval | Cron | Runs/day |
|---|---|---|
| 5 min | */5 * * * * |
288 |
| 15 min | */15 * * * * |
96 |
| 30 min | */30 * * * * |
48 |
| 1 hour | 0 * * * * |
24 |