Daniel's laying out a deployment question that hits right where a lot of solo devs and small teams live. The code's on GitHub, it's Dockerized, and it needs to land on a VPS. The question isn't whether to automate — it's which knobs to turn. He's asking about two independent choices. First, where do you build the image: in a registry like GHCR or Docker Hub, or directly on the deployment machine? Second, how does the deployment machine find out there's a new build: cron polling, a self-hosted GitHub Actions runner, or a webhook that gets pinged the moment the build is ready? And then the third question — when does this whole hand-rolled setup start to creak, and what are the signs you should look at dedicated CI/CD?
The two axes are the right way to think about it. Where you build and how you trigger — those decisions don't depend on each other, but they do shape each other's tradeoffs. And the simplest viable starting point is a single VPS, a Dockerfile, and a cron job that pulls every few minutes. Let's build up from there.
Before we get into the trigger mechanisms, the build location is the one that bites people first. Because it's not about philosophy — it's about whether your server survives the build process.
Right. So axis one. You've got two options. Option A: registry-first. GitHub Actions builds the image, pushes it to GHCR or Docker Hub, and the deployment machine only ever pulls. Option B: on-machine build. The deployment machine clones the repo and runs docker build itself. Those are different operating models, and the choice is mostly about what your VPS can physically handle.
I've watched a five-dollar DigitalOcean droplet try to run npm install inside a Docker build. It's not pretty. The thing just... gives up on life.
That's the core of it. A 512-megabyte or 1-gigabyte VPS running a Node.js or Python project — the build step can easily spike past the available RAM. Multi-stage Dockerfiles help, but the install phase in a Node project, with all those dependencies resolving, will happily consume 800 megabytes before it's done. If you're also running a database or a web server on that same box, docker build will OOM the machine. It'll get killed by the kernel, or it'll swap itself into a coma.
Swap itself into a coma is the technical term.
It's the one I'm using. Registry-first avoids that entirely. The build happens in GitHub's CI environment, which has plenty of resources. Actions runners get two-core CPUs and seven gigs of RAM on the standard tier. The deployment machine just does a docker pull and a restart. It stays thin — no build toolchain, no compilers, no source code sitting on disk. Just the Docker daemon and the image.
That last part — no source code on the server — is underrated. If someone gets into your VPS, they don't get your repo. They get a running container and nothing else.
And GitHub's own documentation recommends exactly this pattern. Build and push to GHCR via Actions, then deploy. They show two paths for the deploy step. One uses a self-hosted runner on the target machine. The other uses SSH from the Actions workflow — the workflow SSHs into the VPS and runs the pull and restart commands directly. No runner agent to maintain, but you do need to manage SSH keys on the VPS and store the private key as an Actions secret.
So that's the official recommendation. Registry-first, Actions does the build, then either a runner or SSH does the deploy. What's the case for building on the machine?
A few things. No registry costs, no image transfer latency, and the build environment matches the runtime environment exactly. If you're building a Go or Rust project where the compile takes thirty seconds and produces a tiny binary, pushing a 5-megabyte image to a registry and pulling it back adds overhead that's hard to justify — especially if your VPS is on a 100-megabit connection and the registry is across an ocean.
Also avoids the class of bug where it builds fine in CI and then falls over on the server because some system library is a different version.
Yes. The build environment is the runtime environment. That's useful. But the downsides are real. The server needs git, the full Docker build toolchain, and potentially compilers and system headers. The build consumes CPU and memory that could be serving traffic. And if the build fails halfway through — maybe a dependency is temporarily unreachable — the server is left in an inconsistent state. The old container might be stopped, the new one never started.
There's also the pull-rate limits to think about. Docker Hub's free tier gives you 100 anonymous pulls per six hours, or 200 authenticated pulls. That sounds like a lot until you've got a cron job polling every five minutes.
Let's do the math on that. A 5-minute cron interval means 12 pulls per hour, 72 per six hours. That's under the 200 limit for authenticated pulls, but it's chewing through most of the anonymous limit. And if you've got more than one image, or you're pulling on multiple servers, you hit it fast. GHCR's free tier is 500 megabytes of storage and 1 gigabyte of egress per month for public images. That's generous for small images but you'll feel it with multi-hundred-megabyte containers.
So for a tiny project, GHCR is basically free and Docker Hub requires you to authenticate to stay under the limits. Neither is a dealbreaker, but the limits shape how aggressive your polling can be.
Which brings us to axis two — the trigger. How does the deployment machine actually know there's a new image to pull? Three patterns, each with a different latency budget and operational complexity. Cron polling, self-hosted runner, webhook.
Cron is the simplest thing that works. A crontab entry that runs docker pull every N minutes, checks if the digest changed, and restarts the container if it did. Zero infrastructure beyond the server. No GitHub integration needed at all — the server just pulls from whatever registry you're using.
The tradeoff is baked into the interval. If you poll every 10 minutes, your deploy latency is somewhere between zero and 10 minutes, averaging 5. That's fine for a personal project or a blog. It's not fine if you're pushing a hotfix and someone's waiting. And every poll is a pull request against your registry limits, even when nothing changed. Most of those requests are no-ops — they pull the manifest, see the digest hasn't changed, and exit. But they still count against the rate limit.
And if you tighten the interval to, say, one minute, you're burning 60 pulls an hour just to check. On Docker Hub's free tier, that's the authenticated limit in a little over three hours.
Right. So cron polling only really works if your interval is loose enough to stay under the rate limits and your latency tolerance is high enough to accept that gap. For a lot of projects, that's true. For others, it's not.
Next step up is the self-hosted runner.
This is where you install the GitHub Actions runner agent directly on your VPS, register it with your repository, and write a workflow that triggers on push. The runner sits there listening for jobs. When you push to main, GitHub dispatches a job to your runner within seconds. The runner executes whatever you told it to do — pull the new image, restart the container, run a health check. No polling, no wasted pull requests, and the runner has access to Actions secrets.
The runner agent itself — what's the maintenance story there?
It's open source. Runs on Linux, macOS, Windows, ARM. It auto-updates, though you need to restart it occasionally. It supports ephemeral runners — the agent spins up, runs one job, and self-destructs. That's useful if you're worried about state persisting between jobs or if you're running on spot instances. For a VPS, you'd typically run it as a persistent systemd service.
And the security angle?
That's the real concern. A self-hosted runner executes arbitrary workflow code from your repository. If someone can open a pull request that modifies the workflow file and you've configured the runner to run on pull_request events, they've got code execution on your server. The mitigation is to only run the runner on push events to protected branches, or to use a separate repository for the deploy workflow that only you can push to. GitHub's docs are clear about this — don't use self-hosted runners on public repositories with pull_request triggers unless you really know what you're doing.
So the runner gives you instant deploys and tight integration with Actions, but you're installing a persistent agent that can execute arbitrary code on your server. You're trading simplicity for latency.
And then there's the third option — webhooks. This is the lightest possible push-based trigger. You run a tiny HTTP server on your VPS — could be a 50-line Go program, a Node.js script, or even a shell script behind systemd socket activation. GitHub sends a POST request to that endpoint on push events. The endpoint validates the HMAC-SHA256 signature using a shared secret, and if it checks out, runs docker pull and docker compose up -d.
No runner agent, no polling, no dependency on GitHub Actions at all.
GitHub webhooks support push, pull_request, release, and workflow_run events. The payload includes the commit SHA, the branch, the author — everything you'd want. The signature verification means you're not executing commands on every random HTTP request that hits your server. And because the webhook fires instantly on push, your deploy latency is effectively zero.
What do you lose?
You lose the workflow ecosystem. No matrix builds, no artifact passing between jobs, no conditional steps, no integration with Actions secrets. The webhook endpoint is a single-purpose thing — it receives a notification and runs a command. If you need the build step to happen in CI and the deploy step to happen on the server, you're either running the webhook alongside a separate Actions workflow for the build, or you're doing the build on the server too, which puts you back in on-machine territory.
So the webhook is great if your build is already happening somewhere else and you just need the server to know about it.
And that's where the hybrid pattern comes together. This is what a lot of teams settle into. Build in GHCR via GitHub Actions — registry-first, all the CI resources, caching, multi-arch builds if you need them. Then a separate deploy workflow triggers on workflow_run — meaning it fires when the build workflow completes successfully. That deploy workflow runs on a self-hosted runner on the VPS, or it hits the webhook endpoint. Either way, the build and the deploy are decoupled.
Walk me through a concrete example of that hybrid.
You've got a Python web app. The Dockerfile is a multi-stage build — first stage installs dependencies, second stage copies the app and runs gunicorn. The build workflow in Actions builds for linux/amd64 and linux/arm64, tags the image with the commit SHA, and pushes to GHCR. That workflow takes maybe three minutes. When it finishes, the workflow_run event fires the deploy workflow. The deploy workflow runs on a self-hosted runner on your VPS. It does docker compose pull, which grabs the new image by digest, then docker compose up -d, which replaces the running container. Then it runs a health check loop — curl localhost every two seconds for 30 seconds — and if the app doesn't respond, it rolls back to the previous image tag.
Without it, docker compose up -d will happily replace a working container with a broken one and call it success.
That's the thing people miss about docker compose. The up -d command only checks that the container started — not that the application inside it is actually serving traffic. A container can be running and completely broken. The health check is the difference between a deploy and a deploy you can trust.
So we've got the two axes mapped. Registry-first versus on-machine for the build. Cron, runner, or webhook for the trigger. The hybrid pattern that combines registry-first with a runner or webhook handles most things. When does it stop being enough?
Four signals. First, rollbacks. If you need version pinning with database migrations — where rolling back the container also means rolling back a schema change — a simple docker pull doesn't cut it. You need tooling that understands the relationship between application versions and database state. Second, multiple servers or environments. If you've got staging, canary, and production, and you need to promote a build through those environments in a coordinated way, a hand-rolled script gets brittle fast. Third, health checks with automatic rollback. The curl loop I described works for one container. If you've got five services that all need to be healthy for a deploy to be considered good, you're writing an orchestration layer by hand. Fourth, team size. Once you cross about three or four developers, deployment coordination becomes a communication problem. Who deployed what, when, and is it safe to deploy on top of it?
The counterintuitive thing is that most teams hit dedicated CI/CD too early, not too late.
I think that's right. The cost of a real CI/CD tool isn't just the money — though hosted services add up. It's the cognitive overhead. Pipeline debugging. YAML that's hundreds of lines long. The temptation to add a staging environment because the tool makes it easy, even when your project doesn't need one. A hand-rolled pipeline of GHCR plus a self-hosted runner plus docker compose handles maybe eighty percent of use cases with a fraction of the complexity.
And the tools that sit in the gap — Coolify, Dokku, CapRover — they're interesting because they're trying to be exactly that. Not a bash script, not Kubernetes. A layer that gives you webhook deploys, SSL termination, and basic rollback without the full platform.
Dokku in particular is basically Heroku in a single binary. You git push to it, it builds the container, runs it, and sets up the reverse proxy. It's been around for years and it's shockingly solid. But it's still a layer — and every layer is something you have to understand when it breaks.
The Docker docs show a pattern that's worth mentioning here. They've got a guide that builds and pushes to GHCR via Actions, then deploys via SSH — not a self-hosted runner. The Actions workflow uses the ssh-action to run commands on the VPS. That's lighter than installing a runner agent, but it means your CI environment has SSH access to your server, which is its own security consideration.
And it ties the deploy to the CI run. If GitHub Actions is having an outage, you can't deploy. With a self-hosted runner or a webhook, the trigger path is shorter and has fewer dependencies on GitHub's infrastructure. Though, realistically, if Actions is down, you've probably got bigger problems.
Let's talk about the multi-architecture thing, because that's where registry-first really shines. If you're building on an x86 machine and deploying to a Raspberry Pi with an ARM chip, on-machine build is the obvious answer — you're building on the architecture you're deploying to. But if you want to build once and deploy to both, you need registry-first with multi-arch builds.
GitHub Actions supports that natively. You can use the docker buildx action to build for linux/amd64 and linux/arm64 in a single workflow, push a manifest list to GHCR, and the deployment machine pulls the right image for its architecture automatically. Doing that on-machine means you need both architectures available at build time, which usually means emulation via QEMU — slow and memory-hungry.
So for anything that touches ARM, registry-first is basically the only sane option unless your build machine is already ARM.
And ARM VPS instances are getting more common. Hetzner's got ARM boxes. Oracle Cloud's free tier is ARM. The Raspberry Pi as a home server is a whole ecosystem. Multi-arch isn't niche anymore.
One thing we haven't touched — the cron polling pattern has a variant that's slightly smarter than blind pulls. You can have the cron job hit the GitHub API first to check if there's a new release or a new commit on the branch you care about, and only pull if something changed. That avoids the registry rate limit problem because you're not pulling the image manifest every time.
A curl to the GitHub API is free and fast. If the commit SHA hasn't changed, skip the pull. That turns a 5-minute cron interval into something that only hits the registry when there's actually a new image. It's still polling, and you still have the latency of the interval, but you're not burning rate limits on no-ops.
The webhook approach eliminates the polling entirely but adds the operational burden of keeping an HTTP endpoint alive and secure. For a lot of people, that's the right tradeoff. For others, the cron-plus-API-check is good enough and they never need to think about it again.
And "good enough and never think about it again" is the platonic ideal of deployment infrastructure for a solo dev.
So to pull all this together. If you're on a small VPS — say 1 gig of RAM or less — build in the registry. GHCR is free for public images up to 500 megs, Docker Hub works if you authenticate. Use a self-hosted runner or a webhook for instant deploys, or a cron job with an API check if you can tolerate a few minutes of latency. The hybrid pattern of build-in-CI, deploy-via-runner handles most things. You know you've outgrown it when you need coordinated rollbacks with migrations, multi-environment promotion, or you've got more than a few people shipping to the same server.
And the single most common wrong belief people hold about this — that building on the deployment machine is simpler because it avoids setting up a registry. In reality, it means installing a build toolchain on your server, exposing source code, and gambling that your build won't OOM the machine while it's serving traffic. On a 512-megabyte VPS, that's not simplicity — it's a bet you're going to lose.
The other one I hear constantly — that a self-hosted runner is just a cron job with extra steps. It's not. A cron job polls. A runner is event-driven. The difference is sub-second latency versus a 5-to-15-minute window, and the runner has access to the entire Actions ecosystem. They're solving different problems.
The pull-rate math alone makes the distinction clear. A cron job polling every 5 minutes on Docker Hub's free tier burns through the anonymous limit in about 8 hours. A self-hosted runner pulls exactly once per deploy.
And if you're on GHCR with a small image, you might never hit the free tier limits at all. Five hundred megabytes of storage and one gig of egress per month is plenty for a typical web app container that's a couple hundred megs.
The thing I keep coming back to is how much of this is about knowing what you don't need. Every deployment tool wants to be the last deployment tool you'll ever use. But most projects don't need canary deployments or blue-green or staged rollouts. They need a new container to replace the old one, reliably, with some way to know if it worked.
And a way to go back if it didn't.
Right. The rollback is the part that separates a script from a pipeline. If your deploy is docker pull and docker compose up -d, your rollback is... also docker pull, but with the old tag. Assuming you kept the old tag. And you remember which one it was.
Which is where the whole thing gets interesting. Because the line between "simple and elegant" and "technical debt" is drawn by how confident you are that you'll remember the old tag at 3 AM when the deploy broke and you're half asleep.
Hilbert: I kept mine in a text file.
What?
Hilbert: Good underscore ones dot txt. In root. Three lines, most recent digests at the top. If the new container didn't come up, I'd SSH in, grab the second line, docker run that one. Took about forty seconds.
Hilbert, that's... when was this?
Hilbert: Twenty fourteen to twenty sixteen. Small SaaS. Customer dashboards. One VPS, private registry, and a bash script called shipit dot sh. Forty-seven lines. It had a trap for SIGINT so you could cancel a bad deploy. Printed a cow when it finished. ASCII art cow. With a speech bubble saying the deploy was done.
Of course it did.
Hilbert: The script never broke. Not once. Did one thing. Pull the image, stop the old container, start the new one, check if it's listening on port three thousand, and if not, grab the last good digest from the file and roll back. The cow only printed on success. If you saw the cow, you were good.
You had rollback. You just implemented it with a text file and a bash function.
Hilbert: It's not complicated. The container either answers on port three thousand or it doesn't. If it doesn't, you run the old one. The file was my deployment history. I still have it. The company failed for unrelated reasons.
The company failed but the deployment script never did. There's a lesson in there somewhere.
The lesson is that a text file with three digests is not a deployment strategy — it's a deployment strategy that worked for one person on one server until it didn't need to work anymore. The question is whether it would have kept working with three servers and two other people shipping code.
Hilbert: Probably not. But I didn't have three servers and two other people. I had one server and me. The script matched the problem.
That's the thing, isn't it. Most of the CI/CD industry is built for problems you don't have yet.
Hilbert: The YAML is the giveaway. You see a GitHub Actions workflow with seventeen steps and a matrix strategy and you think — this person is deploying a static site to a single VPS. They could have written forty-seven lines of bash. But bash doesn't give you a green checkmark.
It does if you run it in Actions.
Hilbert: Then you're back to YAML.
I want to sit with the good underscore ones dot txt for a second. Because it's ridiculous, but it's also exactly what a rollback is. A pointer to a known-good state. Whether you store it in a text file or an etcd cluster or a Kubernetes configmap, the concept is the same.
The difference is what happens when you're not the one doing the rollback. If Hilbert's asleep and someone else needs to revert a bad deploy, they have to know that the file exists, where it is, and what to do with it. That's the coordination problem. The tooling isn't for the person who built it — it's for everyone else.
Hilbert: That's fair. I was the only one who touched it. If I'd hired someone, the first thing I'd have done is write a README.
And the second thing?
Hilbert: Probably bought a CI/CD tool and regretted it.
There's an open question here that I think is worth leaving on the table. Container registries are adding features — GHCR now supports OCI artifacts and attestations, Docker Hub has automated builds with webhooks. As those features grow, does the line between hand-rolled and CI/CD blur further? Or do the features just make the hand-rolled pattern viable for longer?
The tools in the gap — Coolify, Dokku, CapRover — they're betting it's the second one. That you can add enough features to a lightweight layer that most people never need the heavy stuff.
They might be right. Dokku has been around for years and it's still just a single binary. It's not trying to be Kubernetes. It's trying to be Heroku without the platform lock-in.
The advice isn't "use this pattern" or "use that tool." It's match the complexity to the problem you actually have, not the problem the tool wants you to have. And if you're not sure, start simpler than you think you need. You'll know when you've outgrown it because the thing that breaks won't be the deploy — it'll be the coordination around the deploy.
That's the signal. When the deploy still works but nobody knows who did it or whether it's safe to do another one, you've hit the limit.
This has been My Weird Prompts. Thanks to our producer Hilbert Flumingtop, who apparently ran his entire SaaS on a text file and an ASCII cow.
If you want to dig deeper into the adjacent corners of this, check out our episodes on GitHub Actions beyond CI/CD and on private container registries — they cover the orchestration side and the registry tradeoffs in more detail.
Find those and everything else at my weird prompts dot com. We'll be back soon.