Ship to Your Own VPS: GitHub Actions CI/CD Without the Cloud Bill
You do not need a platform, you need a pipeline
The default advice for deployment in 2026 is to hand your app to a platform, click a button and pay per seat, per build minute, per bandwidth unit. That advice is fine until your bill is 180 dollars a month for an app serving 2,000 users, or your build minutes run out on the 14th, or your data needs to live in a specific jurisdiction.
The alternative is unfashionable and extremely effective: a single VPS costing 5 to 12 dollars a month, Docker Compose, and GitHub Actions as the thing that pushes the button. GitHub Actions gives public repositories unlimited minutes and private repositories a free monthly allowance that a small team rarely exhausts, because you only build on merge. The container registry (GHCR) is free for public images and generous for private ones.
This article is the whole pipeline: test, build, push, deploy, verify, roll back. Real YAML, no hand-waving.
The architecture
- You merge to main.
- GitHub Actions runs the test suite.
- If green, it builds a Docker image and pushes it to ghcr.io tagged with the git SHA.
- It SSHes into your VPS, tells Compose to pull that exact tag and restart.
- It polls a health endpoint. If the health check fails, it puts the previous tag back.
Nothing here is clever. The value is that it is written down and it runs the same way every time, including at 2am when you are the one who is tired.
Prepare the server once
Do not deploy as root. Create a dedicated user whose only job is to run this app:
# On the VPS, as root
adduser --disabled-password --gecos "" deploy
usermod -aG docker deploy
mkdir -p /srv/myapp
chown -R deploy:deploy /srv/myapp
Generate a key pair specifically for CI. Never reuse your personal key:
# On your laptop
ssh-keygen -t ed25519 -C "github-actions@myapp" -f ~/.ssh/myapp_deploy -N ""
# Install the public half on the server
ssh-copy-id -i ~/.ssh/myapp_deploy.pub deploy@YOUR_SERVER_IP
# Print the private half; this is what goes into the GitHub secret
cat ~/.ssh/myapp_deploy
You cannot usefully restrict this key by source IP, because GitHub-hosted runners come from very wide address ranges. So keep the blast radius small instead: the deploy user owns only /srv/myapp, is in the docker group, and has no passwordless sudo. If the key leaks, the attacker gets your app, which is bad, rather than your whole server, which is worse.
Now add the repository secrets:
gh secret set VPS_HOST --body "203.0.113.10"
gh secret set VPS_USER --body "deploy"
gh secret set VPS_SSH_KEY < ~/.ssh/myapp_deploy
gh secret set VPS_PORT --body "22"
The compose file on the server
Put this at /srv/myapp/compose.yaml. Note that it pulls a published image rather than building on the server. Building on a 1GB VPS while it is also serving traffic is how you get an OOM kill during a deploy.
name: myapp
services:
api:
image: ghcr.io/OWNER/myapp:${APP_TAG:-latest}
restart: unless-stopped
env_file: /srv/myapp/.env
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8000/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
env_file: /srv/myapp/.env.db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
Two deliberate choices. The API port binds to 127.0.0.1, not 0.0.0.0, so it is not exposed to the internet directly - put Caddy or Nginx in front for TLS. And the tag comes from an environment variable, which is the hook the deploy script uses.
The workflow file
Save as .github/workflows/deploy.yml.
name: CI and Deploy
on:
push:
branches: [main]
pull_request:
# Never let two deploys race each other
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app_test
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 3s
--health-retries 10
ports:
- 5432:5432
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
env:
DATABASE_URL: postgres://app:secret@localhost:5432/app_test
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
envs: APP_TAG,GHCR_TOKEN,GH_ACTOR
script: |
set -euo pipefail
cd /srv/myapp
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GH_ACTOR" --password-stdin
# Remember what is currently running, for rollback
PREVIOUS=$(grep '^APP_TAG=' .env.deploy 2>/dev/null | cut -d= -f2 || echo "")
echo "APP_TAG=$APP_TAG" > .env.deploy
export APP_TAG
docker compose pull api
docker compose up -d --wait --wait-timeout 120 api
# Verify from outside the container
for i in $(seq 1 10); do
if curl -fsS http://127.0.0.1:8000/healthz > /dev/null; then
echo "Healthy on $APP_TAG"
docker image prune -f
exit 0
fi
sleep 5
done
echo "Health check failed, rolling back to $PREVIOUS"
if [ -n "$PREVIOUS" ]; then
export APP_TAG="$PREVIOUS"
echo "APP_TAG=$PREVIOUS" > .env.deploy
docker compose up -d --wait api
fi
exit 1
env:
APP_TAG: ${{ github.sha }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_ACTOR: ${{ github.actor }}
The details that matter
set -euo pipefail is doing real work. The ssh-action used to have a script_stop option; it has been removed and the documented replacement is set -e in your script. Without it, a failed docker compose pull prints an error, the script keeps going, the deploy step reports success, and you find out from a user. The single most common broken pipeline I see is a green tick over a failed deploy.
Deploy the SHA, never latest. The latest tag is a moving pointer. If you deploy latest, you cannot answer "what code is on production right now", and rollback becomes guesswork. Tag with the commit SHA and the answer is one command away.
--wait is not the health check. The --wait flag makes Compose block until containers report healthy per their healthcheck, which is checked from inside the container. The curl loop afterwards checks from outside, through the published port. Both can fail independently, and you want to know about both.
concurrency with cancel-in-progress: false. If two merges land a minute apart, you want the second deploy to queue behind the first, not cancel it mid-flight and leave a half-pulled image. This is the opposite of what you want for PR test runs, where cancelling is correct.
environment: production. Attaching a job to a GitHub Environment lets you add a required reviewer, so deploys wait for a human click. On a two-person team I leave it automatic for the API and require approval for anything that runs migrations.
Where migrations go
Resist the temptation to put migrations in the container's start command. If you scale to two replicas, both run migrations simultaneously and you get a lock fight or a corrupted state. Run them as a discrete step:
docker compose run --rm api npm run migrate
docker compose up -d --wait api
And make those migrations idempotent, because this step will be retried by someone eventually.
What this costs and what you give up
| Item | Typical monthly cost |
|---|---|
| VPS, 2 vCPU / 4GB (Hetzner, DigitalOcean, Contabo) | USD 5 - 14 |
| GitHub Actions (private repo, low volume) | USD 0 within free minutes |
| GHCR image storage | USD 0 to a few dollars |
| Domain and TLS (Caddy with automatic Let's Encrypt) | USD 1 for the domain, TLS free |
What you give up is honest to state: there is no automatic failover, so a dead server is downtime until you rebuild it. Your backups are your responsibility, and if you have not restored one, you do not have backups. There is a brief gap during restart unless you add a second replica behind the proxy. And you now own kernel updates and fail2ban.
For a small team serving a real but not enormous user base, that trade is usually correct. You buy back the difference in cash and in the ability to understand your own stack top to bottom. When you outgrow it, the pipeline you built here maps almost directly onto a managed platform, because the artifact is already a container and the deploy is already one command.