</>CodeWithKarani

Ship to Your Own VPS: GitHub Actions CI/CD Without the Cloud Bill

Karani GeoffreyKarani Geoffrey7 min read

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

  1. You merge to main.
  2. GitHub Actions runs the test suite.
  3. If green, it builds a Docker image and pushes it to ghcr.io tagged with the git SHA.
  4. It SSHes into your VPS, tells Compose to pull that exact tag and restart.
  5. 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

ItemTypical 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 storageUSD 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.

#ci-cd#github-actions#vps#docker#deployment
Keep reading

Related articles