Docker VPS Deploy
Overview
Build Docker image in CI, compress with gzip, transfer to VPS via rsync over SSH, load and run with Docker Compose. No container registry required — the image travels as a .tar.gz file.
When to Use
- VPS with SSH access, Docker, and Docker Compose installed
- No container registry in the workflow (no Docker Hub, ECR, GHCR, etc.)
- Single-server or small-fleet deployment
- User asks to generate a GitHub Actions workflow for VPS deployment via rsync/SSH
When NOT to Use
- Container registry already available → push/pull is simpler and faster
- Cloud-managed deployments (ECS, Cloud Run, Fly.io, Railway, Render)
- Multi-node orchestration (Kubernetes, Docker Swarm across nodes)
- Non-Docker deployments (bare-metal, systemd services)
Prerequisites
On the VPS before first run:
- Docker and Docker Compose v2 (
docker compose, not docker-compose) installed
- SSH key-based authentication configured for deploy user
- Deploy directory exists and is writable (e.g.,
/opt/app)
docker-compose.yml present in the repository root
Required Secrets
| Secret |
Description |
Example |
SSH_HOST |
VPS IP or hostname |
203.0.113.10 |
SSH_USER |
SSH login user |
deploy |
SSH_KEY |
Private SSH key (Ed25519 PEM) |
Contents of ~/.ssh/id_ed25519 |
SSH_PORT |
SSH port |
22 |
Add in: GitHub repo → Settings → Secrets and variables → Actions.
Core Pipeline Pattern
name: Deploy to VPS
on:
push:
branches: [main]
paths-ignore:
- "*.md"
- "docs/**"
env:
IMAGE_NAME: my-app
DEPLOY_DIR: /opt/app
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
load: true
tags: ${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Save and compress image
run: docker save ${{ env.IMAGE_NAME }}:latest | gzip > image.tar.gz
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_KEY }}
- name: Add VPS to known hosts
run: ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Transfer files to VPS
run: |
rsync -avz \
-e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=yes" \
image.tar.gz docker-compose.yml \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ env.DEPLOY_DIR }}/
- name: Deploy on VPS
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_KEY }}
script: |
cd ${{ env.DEPLOY_DIR }}
gunzip -c image.tar.gz | docker load
docker compose down
docker compose up -d
docker image prune -f
rm -f image.tar.gz
Key Optimizations
- Layer caching (
type=gha, mode=max): GitHub Actions cache backend. Skips rebuild of unchanged layers. mode=max caches all intermediate layers, not just the final stage.
- Gzip compression:
docker save outputs uncompressed tar. Gzip reduces image size 60–70% before transfer. For images >2 GB, consider zstd (docker save ... | zstd) for faster compression.
webfactory/ssh-agent: Loads the deploy key into ssh-agent in memory — no key file written to disk. Integrates with the system SSH client, so rsync and other SSH commands work without -i flags.
appleboy/ssh-action: Executes remote Docker commands over SSH with a clean YAML interface. Errors and stdout are surfaced natively in the Actions log without manual heredoc handling.
- rsync
-avz: Archive mode + compression during transfer. Subsequent deploys only transfer changed bytes (docker-compose.yml updates are nearly instant).
load: true: Required in build-push-action when NOT pushing to a registry. Makes the built image available locally for docker save.
Security Considerations
- Never use
StrictHostKeyChecking=no. Use ssh-keyscan to populate known_hosts before connecting. Disabling host key checking enables MITM attacks.
- SSH key never written to disk.
webfactory/ssh-agent loads the key into ssh-agent in memory. appleboy/ssh-action handles its own key internally — no files, no CLI args.
- Ed25519 keys preferred over RSA — smaller, faster, same security.
- Dedicated deploy user on the VPS: non-root, member of
docker group, write access to deploy dir only.
permissions: contents: read — least-privilege GITHUB_TOKEN scoping.
Common Mistakes
| Mistake |
Why It Fails |
Fix |
Using GHCR/Docker Hub instead of docker save |
Doesn't match the no-registry requirement; adds registry credentials complexity |
Use docker save | gzip > image.tar.gz + rsync |
StrictHostKeyChecking=no |
Disables MITM protection |
Use ssh-keyscan + StrictHostKeyChecking=yes |
Missing load: true in build step |
Image not available locally for docker save |
Add load: true to build-push-action |
Using docker-compose (v1 binary) |
Deprecated; may not exist on VPS |
Use docker compose (v2 plugin, no hyphen) |
No timeout-minutes |
Stuck deploy blocks runner for 6 hours |
Set timeout-minutes: 20 on the job |
Not cleaning up image.tar.gz on VPS |
Disk fills up over repeated deploys |
rm -f image.tar.gz after docker load |
| Hardcoding SSH port 22 |
Breaks when VPS uses non-standard port |
Parameterize via SSH_PORT secret |
Cross-Reference
For GitHub Actions syntax fundamentals — workflow YAML structure, triggers, job orchestration, caching patterns, action SHA pinning, and the 13 common anti-patterns — use the github-actions skill:
npx skills add oakoss/agent-skills/github-actions
This skill focuses exclusively on the Docker + VPS rsync-based deployment pattern and delegates all GitHub Actions syntax questions to that skill.
1---2name: docker-vps-deploy3description: Use when deploying a Dockerized application to a VPS (Linux server) via SSH without a container registry, generating a GitHub Actions pipeline that uses docker save, gzip compression, and rsync to transfer images. Triggers: "deploy to VPS", "rsync docker image", "docker save and load", "VPS CI/CD", "SSH deploy pipeline", "deploy without registry", "transfer docker image via SSH".4license: MIT5---67# Docker VPS Deploy89## Overview1011Build Docker image in CI, compress with gzip, transfer to VPS via rsync over SSH, load and run with Docker Compose. No container registry required — the image travels as a `.tar.gz` file.1213## When to Use1415- VPS with SSH access, Docker, and Docker Compose installed16- No container registry in the workflow (no Docker Hub, ECR, GHCR, etc.)17- Single-server or small-fleet deployment18- User asks to generate a GitHub Actions workflow for VPS deployment via rsync/SSH1920## When NOT to Use2122- Container registry already available → push/pull is simpler and faster23- Cloud-managed deployments (ECS, Cloud Run, Fly.io, Railway, Render)24- Multi-node orchestration (Kubernetes, Docker Swarm across nodes)25- Non-Docker deployments (bare-metal, systemd services)2627## Prerequisites2829On the VPS before first run:30- Docker and Docker Compose v2 (`docker compose`, not `docker-compose`) installed31- SSH key-based authentication configured for deploy user32- Deploy directory exists and is writable (e.g., `/opt/app`)33- `docker-compose.yml` present in the repository root3435## Required Secrets3637| Secret | Description | Example |38|--------|-------------|---------|39| `SSH_HOST` | VPS IP or hostname | `203.0.113.10` |40| `SSH_USER` | SSH login user | `deploy` |41| `SSH_KEY` | Private SSH key (Ed25519 PEM) | Contents of `~/.ssh/id_ed25519` |42| `SSH_PORT` | SSH port | `22` |4344Add in: GitHub repo → Settings → Secrets and variables → Actions.4546## Core Pipeline Pattern4748```yaml49name: Deploy to VPS5051on:52 push:53 branches: [main]54 paths-ignore:55 - "*.md"56 - "docs/**"5758env:59 IMAGE_NAME: my-app60 DEPLOY_DIR: /opt/app6162jobs:63 deploy:64 runs-on: ubuntu-latest65 timeout-minutes: 2066 permissions:67 contents: read6869 steps:70 - name: Checkout71 uses: actions/checkout@v47273 - name: Set up Docker Buildx74 uses: docker/setup-buildx-action@v37576 - name: Build Docker image77 uses: docker/build-push-action@v678 with:79 context: .80 file: ./Dockerfile81 load: true82 tags: ${{ env.IMAGE_NAME }}:latest83 cache-from: type=gha84 cache-to: type=gha,mode=max8586 - name: Save and compress image87 run: docker save ${{ env.IMAGE_NAME }}:latest | gzip > image.tar.gz8889 - name: Setup SSH agent90 uses: webfactory/ssh-agent@v0.9.091 with:92 ssh-private-key: ${{ secrets.SSH_KEY }}9394 - name: Add VPS to known hosts95 run: ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts9697 - name: Transfer files to VPS98 run: |99 rsync -avz \100 -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=yes" \101 image.tar.gz docker-compose.yml \102 ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ env.DEPLOY_DIR }}/103104 - name: Deploy on VPS105 uses: appleboy/ssh-action@v1.2.0106 with:107 host: ${{ secrets.SSH_HOST }}108 username: ${{ secrets.SSH_USER }}109 port: ${{ secrets.SSH_PORT }}110 key: ${{ secrets.SSH_KEY }}111 script: |112 cd ${{ env.DEPLOY_DIR }}113 gunzip -c image.tar.gz | docker load114 docker compose down115 docker compose up -d116 docker image prune -f117 rm -f image.tar.gz118```119120## Key Optimizations121122- **Layer caching (`type=gha`, `mode=max`):** GitHub Actions cache backend. Skips rebuild of unchanged layers. `mode=max` caches all intermediate layers, not just the final stage.123- **Gzip compression:** `docker save` outputs uncompressed tar. Gzip reduces image size 60–70% before transfer. For images >2 GB, consider `zstd` (`docker save ... | zstd`) for faster compression.124- **`webfactory/ssh-agent`:** Loads the deploy key into ssh-agent in memory — no key file written to disk. Integrates with the system SSH client, so rsync and other SSH commands work without `-i` flags.125- **`appleboy/ssh-action`:** Executes remote Docker commands over SSH with a clean YAML interface. Errors and stdout are surfaced natively in the Actions log without manual heredoc handling.126- **rsync `-avz`:** Archive mode + compression during transfer. Subsequent deploys only transfer changed bytes (`docker-compose.yml` updates are nearly instant).127- **`load: true`:** Required in `build-push-action` when NOT pushing to a registry. Makes the built image available locally for `docker save`.128129## Security Considerations130131- **Never use `StrictHostKeyChecking=no`.** Use `ssh-keyscan` to populate `known_hosts` before connecting. Disabling host key checking enables MITM attacks.132- **SSH key never written to disk.** `webfactory/ssh-agent` loads the key into ssh-agent in memory. `appleboy/ssh-action` handles its own key internally — no files, no CLI args.133- **Ed25519 keys preferred** over RSA — smaller, faster, same security.134- **Dedicated deploy user** on the VPS: non-root, member of `docker` group, write access to deploy dir only.135- **`permissions: contents: read`** — least-privilege GITHUB_TOKEN scoping.136137## Common Mistakes138139| Mistake | Why It Fails | Fix |140|---------|-------------|-----|141| Using GHCR/Docker Hub instead of `docker save` | Doesn't match the no-registry requirement; adds registry credentials complexity | Use `docker save \| gzip > image.tar.gz` + rsync |142| `StrictHostKeyChecking=no` | Disables MITM protection | Use `ssh-keyscan` + `StrictHostKeyChecking=yes` |143| Missing `load: true` in build step | Image not available locally for `docker save` | Add `load: true` to `build-push-action` |144| Using `docker-compose` (v1 binary) | Deprecated; may not exist on VPS | Use `docker compose` (v2 plugin, no hyphen) |145| No `timeout-minutes` | Stuck deploy blocks runner for 6 hours | Set `timeout-minutes: 20` on the job |146| Not cleaning up `image.tar.gz` on VPS | Disk fills up over repeated deploys | `rm -f image.tar.gz` after `docker load` |147| Hardcoding SSH port 22 | Breaks when VPS uses non-standard port | Parameterize via `SSH_PORT` secret |148149## Cross-Reference150151For GitHub Actions syntax fundamentals — workflow YAML structure, triggers, job orchestration, caching patterns, action SHA pinning, and the 13 common anti-patterns — use the `github-actions` skill:152153```bash154npx skills add oakoss/agent-skills/github-actions155```156157This skill focuses exclusively on the Docker + VPS rsync-based deployment pattern and delegates all GitHub Actions syntax questions to that skill.