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:
- Pull-based: a server clones the repo and runs
git pullon a schedule or on command. Authentication uses a deploy key. - 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.
- 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.pubIn 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 yesThen clone using the alias:
git clone git@github-myapp:acme/myapp.gitTest the connection before you rely on it:
ssh -T git@github-myappWhen 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:
concurrencywithcancel-in-progress: falsestops two pushes from deploying at the same time. Overlapping deploys that race each other ondocker compose upare a classic source of half-updated containers.- Immutable tags (
sha-...) instead oflatest. You always know exactly what is running, and rolling back means redeploying an older tag. DEPLOY_KNOWN_HOSTSpins the server's host key. Workflows that runssh -o StrictHostKeyChecking=noare trusting whatever answers on that IP.environment: productionlets 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_keysfor a dedicated low-privilegedeployuser.
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:
| Incident | Root cause | Pipeline fix? |
|---|---|---|
| Postgres refuses writes for hours | Disk full: WAL, logs, old images | No |
| App down after reboot | Service not set to restart, or Docker not enabled at boot | No |
| Site shows TLS error | Certbot renewal failed silently | No |
| Data loss after bad migration | No tested backups | No |
| Slow site during a spike | Single VPS, no scaling | No |
| Surprise invoice | Usage-based platform, no spend limits set | No |
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 VPS | Managed platform connected to GitHub | |
|---|---|---|
| Deploy trigger | Push to branch | Push to branch |
| Pipeline to maintain | Workflow YAML, deploy script, SSH keys | None or minimal |
| Database | You install, tune, back up, and monitor it | Managed, provisioned from a dashboard |
| Scaling | Manual: bigger VPS or add a load balancer | Automatic |
| TLS, reverse proxy | Nginx or Caddy config, cert renewal | Handled |
| Cost profile | Low, flat VPS price, plus your time | Plan price plus usage |
| Control | Total | Less, within the platform's model |
| Who gets paged | You | The 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:
- 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.
- Set environment variables in the dashboard rather than in a
.envfile on a server. - Attach a managed Postgres database. No
apt install postgresql, no disk-usage cron job, no hand-rolled backup script. - Add S3-compatible storage with CDN for user uploads and static assets, in the same dashboard.
- 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:
- Make the app stateless. Move uploaded files from local disk to S3-compatible storage. Move sessions to the database or Redis.
- Put all config in environment variables. Anything read from a file on the server needs to become an env var or a secret.
- Add a health check endpoint (
/healthz) that checks the database connection. - Dump and restore the database into the managed instance, then test the restore by running the app against it in a staging environment.
- Deploy in parallel on the new platform while the old server still serves traffic.
- Lower DNS TTL a day before, then switch DNS.
- Keep the old server read-only for a week before shutting it down, in case you need to recover anything.
- 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.