My production deploys take 15 minutes. I don't watch them. I don't hold my breath. I make tea, and by the time it steeps, the new version is live.

This wasn't always the case.

Two years ago, deploys were a ritual. Block out an hour. Tell the team. Run the script manually. Watch the logs. Pray. Fix the thing that broke. Run it again. Pray harder. The whole process took 2-3 hours and left everyone exhausted. We deployed on Fridays because we hated ourselves.

If that sounds familiar, you're running deploys on adrenaline instead of systems. As of March 2026, there's zero reason for that. Here's how I turned a 3-hour anxiety festival into 15 boring minutes. ⚙️

The problem: eleven steps in someone's head

Deploy — the process of pushing new code from your computer to the server where users actually see it — meant 11 different things at once. Pull the code. Install dependencies (external libraries your app needs). Run migrations (update the database structure to match new code). Build assets. Restart the service. Clear the cache. Update the reverse proxy. Check the health endpoint.

All of it lived on a wiki page that was 40% outdated. New team members were terrified. Senior ones were just tired.

The fix wasn't a fancy tool. It was discipline.

Step 1: One script, one command

I wrote a single bash script — a small program that runs commands in sequence, like a recipe the computer follows line by line. Called it deploy.sh. It does all 11 things in order. If any step fails, the whole thing stops and tells you which step broke and why.

#!/bin/bash
set -euo pipefail

APP=$1
DEPLOY_LOG="/var/log/deploys/${APP}-$(date +%Y%m%d-%H%M%S).log"

echo "Deploying ${APP}..." | tee "$DEPLOY_LOG"

# Each step: name, command, rollback
deploy_step "Pull code"       "git -C /srv/${APP} pull origin main"
deploy_step "Install deps"    "cd /srv/${APP} && pip install -r requirements.txt"
deploy_step "Run migrations"   "cd /srv/${APP} && python manage.py migrate"
deploy_step "Build assets"    "cd /srv/${APP} && npm run build"
deploy_step "Restart service"  "systemctl restart ${APP}"
deploy_step "Health check"    "curl -sf http://localhost:8080/health"

echo "Deploy complete: ${APP}" | tee -a "$DEPLOY_LOG"

The deploy_step function wraps each command with logging, timing, and error handling. If the health check fails, it automatically restarts the previous version — a rollback, meaning "undo and go back to what was working."

Total setup time: 45 minutes. That 45 minutes has saved hundreds of hours since. The key idea comes straight from the Twelve-Factor App methodology — one codebase, one build, one deploy command.

Step 2: CI/CD — the robot that deploys for you

CI/CD stands for Continuous Integration / Continuous Deployment. Plain English: a system that automatically tests your code when you push changes (that's the CI part), and if tests pass, automatically deploys it to the server (that's the CD part). You push code, the robot handles the rest.

For a small team, you don't need Jenkins (a heavyweight automation server), ArgoCD (a Kubernetes deployment tool), or a Kubernetes cluster (a container orchestration system — if those words mean nothing, good, you don't need it). You need GitHub Actions and a server with SSH access. GitHub Actions launched in 2019 and has matured into the default CI/CD choice for teams already on GitHub — stable, well-documented, and free for small workloads.

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest --tb=short

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: success()
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_KEY }}
          script: bash /srv/myapp/deploy.sh myapp

That's the entire pipeline — a sequence of automated steps that code travels through from your laptop to production. Push to main (the primary branch of your code). Tests run on GitHub's servers. If they pass, GitHub connects to your server via SSH (a secure remote connection protocol) and runs deploy.sh.

No containers. No orchestrators. No YAML files spanning 200 lines. GitHub Actions' free tier gives you 2,000 minutes per month — more than enough for a small team deploying 2-3 times a day.

Step 3: A health check that actually checks health

Most health checks are a /health URL returning {"status": "ok"}. That tells you the web server is running. It tells you nothing about whether the app actually works. It's like asking someone "are you alive?" when you should be asking "can you see, hear, and move your fingers?"

My health checks verify three things:

@app.get("/health")
async def health():
    checks = {
        "database": await check_db_connection(),
        "cache": await check_redis_ping(),
        "disk": check_disk_space_above(20),  # percent
    }
    all_ok = all(checks.values())
    return JSONResponse(
        status_code=200 if all_ok else 503,
        content={"status": "healthy" if all_ok else "degraded", "checks": checks}
    )

Database down? Health check fails. Redis (an in-memory cache — a fast temporary storage layer) unreachable? Fails. Disk 95% full? Fails. The deploy script calls this endpoint after restart. If it returns 503 (the HTTP status code for "service unavailable"), the deploy rolls back automatically.

This single endpoint has caught more post-deploy issues than all our manual testing combined. ⚙️

Step 4: Notifications, not dashboards

I don't sit and watch monitoring dashboards. The deploy script sends exactly two messages:

  1. Deploy started — "Deploying myapp at 14:32. Triggered by commit abc123."
  2. Deploy finished — "myapp deployed in 14m 22s" or "myapp deploy FAILED at step: health check. Rolled back."

These go to a Telegram channel. If I see a failure notification, I investigate. If I see a success, I sip my tea.

No Grafana (a popular dashboard tool). No PagerDuty (an alerting service that wakes you at 3 AM). For a small team running 2-3 services, a Telegram message is the right level of complexity. Complexity should match the size of the problem, not the size of your ambition. 🫶

Step 5: Deploy on Monday morning

We stopped deploying on Fridays. We deploy Monday and Wednesday mornings now.

Why? Because if something breaks on Monday, you have five days to fix it with fresh brains. Friday deploys mean weekend debugging. Weekend debugging means burnout. Burnout means mistakes. Mistakes mean more weekend debugging.

That's a negative feedback loop — a cycle where each bad outcome causes the next one. It's not a deployment strategy.

The boring deploy schedule: Monday and Wednesday mornings. Nothing after 3 PM. Nothing on Fridays. Nothing during lunch. If it's not ready for Monday's window, it waits. Waiting is cheap. Burnout is expensive.

What you're giving up

Honesty time. This setup has tradeoffs.

No zero-downtime deploys. When systemctl restart runs, there's a 2-5 second gap where the app is unavailable. For a service handling millions of requests, that matters. For most small teams, nobody notices. If you outgrow this, look into blue-green deployments (running two copies of your app and switching traffic between them) — but don't start there.

Single server, single point of failure. If the server dies, everything dies. Redundancy costs money. For most projects under $10K monthly revenue, a single well-maintained server with daily backups is the right answer. Scale problems are good problems to have.

No containerization. Docker (a tool that packages your app with all its dependencies into an isolated unit) is wonderful for complex environments. For a Python app with a database and Redis on one server, it's overhead. You can always add it later.

The DORA metrics research from Google shows that elite teams deploy on demand with lead times under an hour. But they also show that you get there incrementally — not by adopting every tool at once.

The result

Before: 3-hour deploys, 1-2 times per week, team of 3 people blocked. After: 15-minute deploys, 2-3 times per week, zero people watching.

Monthly time saved: roughly 24 hours of collective team time. Annual cost of the CI/CD setup: $0 (GitHub Actions free tier plus a bash script). Setup time: one weekend.

Make it boring

The goal of good ops isn't to make deploys exciting. It's to make them so boring you forget they happen.

When you deploy without adrenaline, you deploy more often. When you deploy more often, each deploy is smaller. When each deploy is smaller, fewer things break. When fewer things break, you sleep better.

That's the whole philosophy. One script. One pipeline. One health check. One notification channel. One calm morning per week.

Make it boring. Make it small. Make it automatic. Go make tea. 🫶