Automatic deployment tools that let you sleep through 2 AM

By Mark Hayes
Automatic deployment tools that let you sleep through 2 AM

Most guides to automatic deployment are about speed: push to main, see it live in 90 seconds. Speed is nice. But if you're a solo founder who has already had a database fall over at 2 AM, or opened a hosting bill twice the size you expected, speed isn't what you're missing. You're missing predictability: every deploy behaves the same way, bad code doesn't reach production, and the monthly cost doesn't move when you ship more often.

This post is about building that kind of pipeline for a Dockerized app. You'll get a mental model, the pieces that make an automatic deployment trustworthy, working config for the parts you own, and an honest view of which tools are worth your time at the MVP stage.

What automatic deployment actually means

Automatic deployment is moving code changes from your repository to production with no manual steps in between. In practice it's a chain:

  1. Trigger: a push or merge to a specific branch.
  2. Verify: tests, linting, type checks.
  3. Build: produce an immutable artifact, usually a Docker image.
  4. Release: run database migrations and swap the running version for the new one.
  5. Confirm: health checks decide whether the new version stays or gets rolled back.

If any link in the chain needs you, whether that's SSHing in, running git pull, or restarting a process, it isn't automatic. It's a manual deploy with a nicer trigger. And the manual step is always the one that fails when you're tired.

The contrarian take: a fast pipeline that sometimes ships broken code is worse than no pipeline, because it trains you to deploy without looking. Optimize for boring first.

The four properties of a pipeline you can trust

Before picking tools, decide what "reliable" means. For a one-person team I'd use four tests.

1. The same artifact everywhere

The image you test is the image you run. That's the whole point of Docker: if it works in docker compose up on your laptop, the same Dockerfile should produce the same behavior in the cloud. Rebuilding separately per environment with slightly different steps is how you get "works on staging".

2. Nothing reaches production without passing checks

Tests must gate the deploy, not run next to it. If CI fails and the deploy still happens, you don't have CI. You have a notification.

3. Failed releases undo themselves

A new version that crashes on boot should never take traffic. Health checks plus keeping the previous version around give you this for free on most managed platforms.

4. Deploy frequency doesn't change the bill

This one is overlooked. If every deploy spins up extra build minutes, preview environments or duplicate databases that you pay for by the minute, shipping more often costs more. You shouldn't be discouraged from shipping a fix because of a meter.

Categories of automatic deployment tools

There are dozens of automated deployment tools. For a Dockerized MVP they fall into four groups.

ApproachExamplesYou manageGood when
Hand-rolled on a VPSGitHub Actions + SSH + docker compose pullServer, OS, proxy, TLS, backupsYou enjoy ops and have the time
Deploy tools for your own serversKamal, Coolify, DokkuServer, OS, backupsYou want PaaS ergonomics on your own box
Image watchersWatchtowerServer and everything on itHobby projects, internal tools
Managed platformsGranite.so, Render, Railway, Fly.io, HerokuYour code and configYou want to own zero servers

The first three are real, well-built options. They share one trait: the server is still yours. When its disk fills up at 2 AM because Postgres WAL files grew, the deployment tool can't help. It only knows how to ship containers.

The managed category removes the server from your responsibilities. The trade-off is less low-level control and a vendor whose pricing model you have to understand. That's why the fourth property above matters so much.

Step 1: make the app deploy-ready

Whatever tool you choose, these changes make an automatic deployment boring in a good way.

Read configuration from the environment. Port, database URL, secrets and feature flags come from environment variables, never from committed files.

Add a real health endpoint. Not just "the process is up", but "the process can do its job":

// Express example
app.get("/healthz", async (req, res) => {
  try {
    await db.query("SELECT 1");
    res.status(200).json({ ok: true });
  } catch (err) {
    res.status(503).json({ ok: false });
  }
});

Keep it cheap. It gets called often. If your platform lets you configure the health check path, point it here.

Shut down gracefully. When a new version takes over, the old container gets SIGTERM. Finish in-flight requests, then exit:

process.on("SIGTERM", () => {
  server.close(() => process.exit(0));
});

Make migrations safe to run automatically. This is where most automatic deployment horror stories come from. Two rules:

  • Expand, then contract. Add the new column in one deploy, start writing to it, and drop the old one in a later deploy. That way the old and new versions can run side by side for a few seconds.
  • Run migrations once. If you run them at container start and the platform starts two instances, you want a lock. Most migration tools (Prisma Migrate, Rails, Django, Alembic with a Postgres advisory lock, golang-migrate) handle concurrent runs or can be made to. Check yours.

A simple entrypoint that migrates, then starts:

#!/bin/sh
set -e
npx prisma migrate deploy
exec node dist/server.js

exec matters: it makes Node PID 1 so it receives SIGTERM directly.

Step 2: gate every deploy with CI

The cleanest setup for a solo founder uses two independent systems:

  • CI (GitHub Actions here) runs checks on every pull request.
  • The platform deploys automatically when main changes.

You connect them with branch protection: main only accepts merges when CI is green. The platform never needs to know about your tests, and your tests never need platform credentials.

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx prisma migrate deploy
      - run: npm test

  docker-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          cache-from: type=gha
          cache-to: type=gha,mode=max

The docker-build job doesn't push anything. It proves the Dockerfile still builds, so a broken build fails in the PR and not during the deploy.

Then, in GitHub, go to Settings → Branches (or Rulesets), protect main, and require the test and docker-build checks. From now on, "merged" means "verified".

Why not deploy from CI directly? You can, and larger teams often do. But it means storing platform credentials in CI and maintaining deploy scripts. With a platform that builds on push, that's code you don't have to own.

Step 3: connect the platform and let it deploy

This is the step where managed platforms earn their keep. On Granite.so it looks like this:

  1. Connect the GitHub or GitLab repository and choose main as the deploy branch.
  2. Granite.so builds from your existing Dockerfile. There's no new build syntax to learn, and no Nginx or TLS config to write.
  3. Set environment variables in the dashboard.
  4. Attach a managed database. You get a connection string, and backups are handled for you.
  5. Push. Every merge to main now builds and deploys automatically.

If you'd rather build images yourself, for example in CI or for a service you don't keep in Git, you can deploy from a Docker image instead of source.

The point isn't the specific clicks. The point is what's missing: no server to provision, no SSH keys to rotate, no certbot renewal to forget, no backup cron job. Each of those is a 2 AM incident you're no longer eligible for.

Step 4: know what happens when a deploy fails

Test your safety net before you need it. Do this on a staging service once:

  1. Break the boot. Deploy a commit that throws on startup. The expected result is that the new version never takes traffic and the old one keeps serving.
  2. Break the health check. Make /healthz return 503. Same expectation.
  3. Roll back by hand. Find the previous deployment in your platform's dashboard and redeploy it. Time it. That's your real recovery time.
  4. Roll back through Git.git revert&& git push. This is often the most reliable rollback because it leaves a trail.

Write the results down in a short RUNBOOK.md. When something breaks at 2 AM, you won't want to think.

Step 5: make the cost of deploying boring too

Predictable deploys aren't only technical. Check these before you start shipping ten times a day:

  • Build minutes: are they included, capped or metered?
  • Preview environments: does each PR get its own database that bills while idle? Turn them off if you don't use them.
  • Instance overlap: during a zero-downtime deploy, two versions run briefly. On per-second billing this is negligible. Just know it happens.
  • Scaling: automatic scaling is great for traffic spikes. Know your ceiling so a bot swarm doesn't turn into a surprise invoice.

On Granite.so the Hobby plan is $9/month with $15 of usage credits included, which covers a small app and managed database at MVP traffic. Pushing more often doesn't change the plan fee. Watch the usage view for the first few weeks so you know your baseline.

A reliability checklist you can finish tonight

  • Config comes only from environment variables
  • /healthz checks the database, not just the process
  • SIGTERM handled, entrypoint uses exec
  • Migrations are backward-compatible and safe to run concurrently
  • CI runs tests and a Docker build on every PR
  • main is protected and requires green checks
  • Platform auto-deploys main from your Dockerfile
  • Managed database with automatic backups attached
  • Failed deploy and rollback rehearsed and written down
  • Billing drivers for builds, previews and scaling understood

Most of this takes an evening. The Dockerfile and health check take 30 minutes, CI takes another 30, and connecting the platform takes 15. What's left is rehearsing failures, which you'll be glad you did.

Where this leaves you

Automatic deployment is only worth having if you trust it enough to stop watching it. That trust comes from gates (CI), guards (health checks and safe migrations) and a platform that takes the server, the database maintenance and the bill anxiety off your plate.

If you want to try that last piece, connect your repo to Granite.so, attach a managed database, and ship your next merge without touching a server. Then go to bed.

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