#4824: Build vs Pull: Docker Deploy Strategies for Solo Devs

Where to build your Docker image and how to trigger deploys — three patterns for small teams on a VPS.

Featuring
Listen
0:00
0:00
Episode Details
Episode ID
MWP-5003
Published
Duration
26:24
Audio
Direct link
Pipeline
V5
TTS Engine
chatterbox-regular
Script Writing Agent
deepseek-v4-pro

AI-Generated Content: This podcast is created using AI personas. Please verify any important information independently.

The deployment question 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. Two independent choices shape the answer: where you build the image and how the deployment machine finds out there's a new build.

On the build location axis, you have two options. Registry-first means GitHub Actions builds the image and pushes it to GHCR or Docker Hub; the deployment machine only ever pulls. This avoids the RAM constraints of building on a small VPS — a 512MB droplet running npm install inside a Docker build can easily OOM, especially if it's also serving traffic. On-machine build means the VPS clones the repo and runs docker build itself. This avoids registry costs and transfer latency, and the build environment matches runtime exactly, but the server needs the full build toolchain and the build consumes resources that could serve traffic.

On the trigger axis, three patterns exist. Cron polling is simplest — a crontab entry runs docker pull every N minutes, checks the digest, and restarts if changed. The tradeoff is latency proportional to the interval, and every poll counts against registry rate limits. A self-hosted GitHub Actions runner on the VPS listens for push events and executes deploy commands within seconds, but installs a persistent agent that can execute arbitrary code — a security concern for public repos. Webhooks offer the lightest push-based trigger: a tiny HTTP server validates an HMAC-SHA256 signature and runs the deploy command, with zero latency and no runner agent to maintain.

The hybrid pattern many teams settle into combines registry-first builds via GitHub Actions with a deploy workflow triggered on workflow_run. The build workflow pushes to GHCR, and when it completes, a deploy workflow on a self-hosted runner pulls the new image and restarts the container — with a health check loop and automatic rollback if the app doesn't respond.

Downloads

Episode Audio

Download the full episode as an MP3 file

Download MP3
Transcript (TXT)

Plain text transcript file

Transcript (PDF)

Formatted PDF with styling

#4824: Build vs Pull: Docker Deploy Strategies for Solo Devs

Corn
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?
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
Swap itself into a coma is the technical term.
Herman
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.
Corn
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.
Herman
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.
Corn
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?
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
Next step up is the self-hosted runner.
Herman
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.
Corn
The runner agent itself — what's the maintenance story there?
Herman
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.
Corn
And the security angle?
Herman
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.
Corn
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.
Herman
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.
Corn
No runner agent, no polling, no dependency on GitHub Actions at all.
Herman
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.
Corn
What do you lose?
Herman
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.
Corn
So the webhook is great if your build is already happening somewhere else and you just need the server to know about it.
Herman
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.
Corn
Walk me through a concrete example of that hybrid.
Herman
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.
Corn
Without it, docker compose up -d will happily replace a working container with a broken one and call it success.
Herman
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.
Corn
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?
Herman
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?
Corn
The counterintuitive thing is that most teams hit dedicated CI/CD too early, not too late.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
So for anything that touches ARM, registry-first is basically the only sane option unless your build machine is already ARM.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
And "good enough and never think about it again" is the platonic ideal of deployment infrastructure for a solo dev.
Corn
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
And a way to go back if it didn't.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
The company failed but the deployment script never did. There's a lesson in there somewhere.
Herman
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.
Corn
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.
Herman
It does if you run it in Actions.

Hilbert: Then you're back to YAML.
Corn
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.
Herman
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.
Corn
And the second thing?

Hilbert: Probably bought a CI/CD tool and regretted it.
Herman
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?
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
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.
Herman
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.
Corn
Find those and everything else at my weird prompts dot com. We'll be back soon.

This episode was generated with AI assistance. Hosts Herman and Corn are AI personalities.