Stop outages: github deploy keys & github actions deploy

By Kate Baker
Stop outages: github deploy keys & github actions deploy

Here is an uncomfortable truth about most solo-founder GitHub deploy setups: the pipeline almost never breaks. The YAML runs, the image builds, the SSH step goes green. Then, three weeks later, Postgres stops accepting writes at 2 AM because the disk filled up with old Docker images and unrotated logs, and nobody finds out until users start emailing.

The deploy "worked". The system didn't.

This post covers the two pieces people search for, GitHub deploy keys and GitHub Actions deploy workflows, and shows how to set them up so they do not cause outages. Then it looks honestly at what a pipeline cannot fix: the server, the database, and the bill at the end of it.

What "github deploy" actually means

GitHub is not a hosting platform for backend apps. When people talk about a GitHub deploy, they mean one of three setups:

  1. Pull-based: a server clones the repo and runs git pull on a schedule or on command. Authentication uses a deploy key.
  2. Push-based via GitHub Actions: a workflow builds your app or Docker image on every push and ships it to a server, usually over SSH.
  3. Platform-connected: a hosting platform watches the repo and builds and deploys on push. GitHub is only the source of truth.

All three get code from GitHub to production. They differ in how much infrastructure you are on the hook for after that.

GitHub deploy keys: the right way to let a server read your repo

A deploy key is an SSH key attached to a single repository rather than to a user account. It is the correct way to give a server access to a private repo, and it is still the most common thing done wrong.

Why not just use your personal SSH key

Plenty of servers are running with the founder's personal key copied into ~/.ssh/. That key has access to every repository you can reach, including write access. If the server is compromised, so is your whole GitHub account. And when you rotate your personal key, production deploys silently break.

A deploy key limits the blast radius to one repo, read-only by default.

Setting one up

On the server:

ssh-keygen -t ed25519 -C "deploy@prod-1 myapp" -f ~/.ssh/myapp_deploy -N ""
cat ~/.ssh/myapp_deploy.pub

In GitHub, open the repository, then Settings → Deploy keys → Add deploy key. Paste the public key, give it a descriptive title, and leave Allow write access unchecked unless the server genuinely needs to push.

Because GitHub requires each deploy key to be unique across repositories, a server that pulls several repos needs several keys. Map them with host aliases in ~/.ssh/config:

Host github-myapp
  HostName github.com
  User git
  IdentityFile ~/.ssh/myapp_deploy
  IdentitiesOnly yes

Host github-worker
  HostName github.com
  User git
  IdentityFile ~/.ssh/worker_deploy
  IdentitiesOnly yes

Then clone using the alias:

git clone git@github-myapp:acme/myapp.git

Test the connection before you rely on it:

ssh -T git@github-myapp

When deploy keys are the wrong tool

Deploy keys are fine for "server pulls code". They get awkward when:

  • You manage many repos, since each one needs its own key. A GitHub App with scoped installation tokens scales better.
  • You want the server not to have source code at all. If you ship Docker images, the server should pull an image from a registry, not clone the repo.

That second point matters more than it sounds. A server that builds from source needs compilers, build tools, and enough CPU and memory to run a build next to production traffic. That is how a deploy on a small VPS turns into an out-of-memory kill of your running app.

A GitHub Actions deploy that fails safely

The more robust pattern: build the image in GitHub Actions, push it to a registry, and have the server pull and restart. Here is a workflow that does that without the usual footguns.

name: deploy

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: false

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.vars.outputs.tag }}
    steps:
      - uses: actions/checkout@v5

      - id: vars
        run: echo "tag=sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ steps.vars.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Deploy
        env:
          TAG: ${{ needs.build.outputs.tag }}
        run: |
          ssh deploy@${{ secrets.DEPLOY_HOST }} \
            "cd /srv/myapp && IMAGE_TAG=$TAG ./deploy.sh"

A few details in there are doing real work:

  • concurrency with cancel-in-progress: false stops two pushes from deploying at the same time. Overlapping deploys that race each other on docker compose up are a classic source of half-updated containers.
  • Immutable tags (sha-...) instead of latest. You always know exactly what is running, and rolling back means redeploying an older tag.
  • DEPLOY_KNOWN_HOSTS pins the server's host key. Workflows that run ssh -o StrictHostKeyChecking=no are trusting whatever answers on that IP.
  • environment: production lets you add required reviewers or restrict which branches can deploy, and scopes the secrets to that environment.
  • The SSH key here is not a GitHub deploy key. It is a key the runner uses to log into your server, so it belongs in the server's authorized_keys for a dedicated low-privilege deploy user.

The server-side script is where outages are prevented

The workflow just calls deploy.sh. That script should refuse to leave production broken:

#!/usr/bin/env bash
set -euo pipefail

: "${IMAGE_TAG:?IMAGE_TAG is required}"
PREVIOUS_TAG=$(cat .current_tag 2>/dev/null || echo "")

# 1. Refuse to deploy onto a nearly full disk
USED=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$USED" -gt 85 ]; then
  echo "Disk at ${USED}%, aborting deploy" >&2
  exit 1
fi

# 2. Pull and start the new version
export IMAGE_TAG
docker compose pull app
docker compose up -d --no-deps app

# 3. Health check, roll back on failure
if ! curl --fail --silent --retry 10 --retry-delay 3 --retry-all-errors \
     http://127.0.0.1:3000/healthz > /dev/null; then
  echo "Health check failed, rolling back to ${PREVIOUS_TAG}" >&2
  if [ -n "$PREVIOUS_TAG" ]; then
    IMAGE_TAG="$PREVIOUS_TAG" docker compose up -d --no-deps app
  fi
  exit 1
fi

echo "$IMAGE_TAG" > .current_tag

# 4. Clean up old images so the disk doesn't fill up
docker image prune -af --filter "until=168h"

And compose.yaml references the tag:

services:
  app:
    image: ghcr.io/acme/myapp:${IMAGE_TAG}
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:3000:3000"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

The logging block is not decoration. Docker's default json-file driver does not rotate logs unless you tell it to, and unrotated container logs are one of the most common reasons a small server's disk fills up.

Note what this script does not handle: database migrations that are not backward compatible, zero-downtime restarts (there is a gap while the container restarts), TLS renewal, OS security patches, or database backups. Each one is another script, another cron job, another thing to monitor.

The outages your pipeline can't prevent

Once the GitHub Actions deploy is solid, the failure modes move elsewhere. In the small-SaaS setups we see, the incidents that actually cost hours are rarely "the deploy failed". They look more like this:

IncidentRoot causePipeline fix?
Postgres refuses writes for hoursDisk full: WAL, logs, old imagesNo
App down after rebootService not set to restart, or Docker not enabled at bootNo
Site shows TLS errorCertbot renewal failed silentlyNo
Data loss after bad migrationNo tested backupsNo
Slow site during a spikeSingle VPS, no scalingNo
Surprise invoiceUsage-based platform, no spend limits setNo

Every row is an operations problem. You can solve each one yourself: monitoring disk usage, backups with restore tests, uptime checks, a second server, spend alerts. But notice what happened: you started out wanting to "deploy from GitHub" and ended up running a small DevOps department of one.

That is the real comparison worth making. Not GitHub versus some other tool, but "GitHub Actions plus infrastructure I own" versus "GitHub as the source, with a platform owning the infrastructure".

GitHub Actions + VPS vs a managed deployment platform

Both are legitimate. Here is a fair look at the trade-offs.

GitHub Actions + your own VPSManaged platform connected to GitHub
Deploy triggerPush to branchPush to branch
Pipeline to maintainWorkflow YAML, deploy script, SSH keysNone or minimal
DatabaseYou install, tune, back up, and monitor itManaged, provisioned from a dashboard
ScalingManual: bigger VPS or add a load balancerAutomatic
TLS, reverse proxyNginx or Caddy config, cert renewalHandled
Cost profileLow, flat VPS price, plus your timePlan price plus usage
ControlTotalLess, within the platform's model
Who gets pagedYouThe platform handles infrastructure; you handle your code

The VPS route is cheaper in raw dollars and gives you full control. If you enjoy ops work, or have specific requirements (custom kernel modules, unusual networking), it is the right choice.

The managed route costs more per month in exchange for not owning the rows in the outage table above. The catch with some managed platforms is billing: purely usage-based pricing can produce a nasty surprise after a traffic spike or a runaway background job. Whichever platform you pick, look at how its pricing behaves under load, and set spend limits or alerts if it offers them. Check current pricing directly on each provider's site, since plans change often.

What moving to Granite.so looks like

If you decide you are done being your own on-call engineer, Granite.so is built for this exact move. You keep GitHub as the source of truth and delete most of the infrastructure code above.

The workflow at a high level:

  1. Connect your GitHub repository. The platform builds on every push to the branch you choose. If you already have a working Dockerfile, it uses it. If you prefer to keep building images in GitHub Actions, you can deploy from a Docker image instead.
  2. Set environment variables in the dashboard rather than in a .env file on a server.
  3. Attach a managed Postgres database. No apt install postgresql, no disk-usage cron job, no hand-rolled backup script.
  4. Add S3-compatible storage with CDN for user uploads and static assets, in the same dashboard.
  5. Let automatic scaling handle traffic spikes instead of resizing a VPS at midnight.

What disappears from your repo: the deploy.sh script, the SSH secrets, the deploy key on the server, the Nginx config, and the Certbot cron. What stays: your code, your Dockerfile, and a push to main.

On pricing, plans bundle monthly usage credits, which makes the bill much easier to predict than pure pay-as-you-go. The Hobby plan is $9 per month with $15 in usage credits, a reasonable place for an MVP. For a production SaaS running an app, a database, and storage together, the Advanced plan at $39 per month, which includes $50 in usage credits, is the more realistic fit. Check the pricing page for current limits before you commit, and size your plan against real traffic, not hopes.

A migration checklist

Whichever managed platform you move to, the order of operations is the same:

  1. Make the app stateless. Move uploaded files from local disk to S3-compatible storage. Move sessions to the database or Redis.
  2. Put all config in environment variables. Anything read from a file on the server needs to become an env var or a secret.
  3. Add a health check endpoint (/healthz) that checks the database connection.
  4. Dump and restore the database into the managed instance, then test the restore by running the app against it in a staging environment.
  5. Deploy in parallel on the new platform while the old server still serves traffic.
  6. Lower DNS TTL a day before, then switch DNS.
  7. Keep the old server read-only for a week before shutting it down, in case you need to recover anything.
  8. Remove the deploy key from GitHub and revoke the Actions SSH secret once the old server is gone. Stale credentials are their own kind of outage waiting to happen.

The takeaway

A GitHub deploy pipeline is a solved problem. Use deploy keys instead of personal keys, build images in Actions, deploy immutable tags, serialize deploys with concurrency, and health-check with automatic rollback. Do that and your pipeline will rarely be the thing that fails.

What fails is everything the pipeline hands off to: disks, databases, certificates, and capacity. You can own all of that, and plenty of founders do. But if the last outage cost you a night's sleep and the last invoice cost you a surprise, it is worth asking whether owning it is the best use of the next six months.

If the answer is no, connect your repo to Granite.so, attach a managed database, and let your next push to main be the last deploy script you think about.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox