You don't need a 5-person dedicated DevOps team or a complex Kubernetes cluster to deploy web applications like a modern tech company.
With Docker, GitHub Actions, and Docker Compose, small engineering teams can achieve automated testing, staging preview environments, and zero-downtime production deployments for under $50/month in infrastructure costs.
The trap most small teams fall into is copying the DevOps setup of a company twenty times their size. Kubernetes, service meshes, Terraform-managed multi-region clusters — none of that is wrong, but every layer you add is a layer you must understand at 2am when a deploy breaks. The goal is not sophistication. The goal is a pipeline you can trust, read end to end in one sitting, and repair under pressure.
This is the setup we run on our own products and hand to clients.
The Lean CI/CD Deployment Workflow
Git Push ──► GitHub Actions ──► 1. Run Pytest & Linting
2. Build Multi-Stage Docker Image
3. Push to Container Registry
4. SSH Deploy to Production VPS
5. Run DB Migrations & Health Check
Five steps, one file of configuration, and no engineer manually copying files onto a server ever again. Everything below is the detail behind those five lines.
3 Pillars of Lean DevOps
- Multi-Stage Docker Builds: Keep production images tiny (<100MB) by separating build dependencies from the final runtime container.
- Automated Pull Request Testing: Run automated unit tests on every pull request to catch bugs before code merges.
- Automated Database Backups: Schedule nightly encrypted PostgreSQL backups to an isolated S3 bucket with 30-day retention policies.
Each pillar earns its place. A large image means slow deploys and a bigger attack surface. Untested merges mean your main branch is a rumour. And an untested backup is not a backup — it is a hope.
Pillar 1: Multi-stage builds that keep images small
A naive Dockerfile installs compilers, build headers and dev tooling into the image that runs in production. The result is 1.2GB of container carrying a compiler nobody needs, pulling over your server's network connection on every deploy.
Split it into two stages — a builder that compiles, and a runtime that only receives the result:
# ---------- build stage ----------
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ---------- runtime stage ----------
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
RUN useradd -m app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -fsS http://localhost:8000/healthz/ || exit 1
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
Two details matter as much as the size: run as a non-root user, and expose a real health endpoint. The first limits the damage if the process is ever compromised; the second is what makes zero-downtime deploys possible later, because your pipeline can ask the container whether it is actually serving traffic rather than assuming.
Pillar 2: PR testing that blocks bad merges
Every pull request runs the same two jobs a human would otherwise do inconsistently — lint and test:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt ruff pytest pytest-django
- run: ruff check .
- run: pytest --cov=apps --cov-fail-under=70
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
DJANGO_SECRET_KEY: test-only-not-a-real-secret
Notice the ephemeral PostgreSQL service. Tests that run against SQLite while production runs PostgreSQL hide an entire class of bug — case sensitivity, constraint behaviour, transaction semantics. Run against the same engine you deploy on, and raise coverage thresholds gradually rather than demanding 90% on day one.
Branch protection then makes it real: require the test job to pass before merge, and require at least one review. A pipeline nobody is forced to obey is documentation, not automation.
Deploying without downtime
With an image built and pushed, the deploy step should be boring. We keep the production directory as a small Compose project and pull a tagged image, never latest:
deploy:
needs: build-and-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -e
cd /srv/app
export IMAGE_TAG=${{ github.sha }}
docker compose pull web
docker compose run --rm web python manage.py migrate --noinput
docker compose up -d --no-deps --scale web=2 web
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:8000/healthz/ && break
sleep 3
done
docker compose up -d --no-deps --scale web=1 web
docker image prune -f --filter "until=168h"
The sequence — migrate, start a second container, wait for it to report healthy, then retire the old one — means the site never serves a connection error during a release. Nginx sits in front as the reverse proxy and simply keeps routing to whichever container answers, so users see nothing but normal response times.
The rollback plan matters as much as the deploy plan. Because every deploy is an immutable image tagged with its commit SHA, rolling back is one command:
cd /srv/app && IMAGE_TAG=<previous-sha> docker compose up -d --no-deps web
Keep the last ten images on the server and you can revert a bad release in under a minute. Tagging with the commit SHA is what makes that possible — latest is not a version, it is an apology waiting to be written.
Migrations are the part that actually breaks things
Application code is easy to roll back. A dropped column is not. Which is why every migration must be backwards compatible with the currently running version of the app, because for a few seconds both versions exist simultaneously.
The pattern is expand and contract:
- Expand — add the new column or table, nullable, in one release. Nothing reads it yet.
- Migrate data — backfill in a background job, never in a request cycle.
- Switch — deploy code that writes to both and reads from the new field.
- Contract — in a later release, once you are certain nothing depends on it, drop the old column.
Two practical rules: never rename a column in one step (add, backfill, switch, drop), and check that your new indexes are non-blocking (CREATE INDEX CONCURRENTLY in PostgreSQL for large tables). A migration that locks a busy table for 90 seconds is a downtime incident with a commit message.
Pillar 3: Backups you have actually restored
Nightly encrypted dumps to an isolated bucket, with retention rules — and a monthly restore rehearsal, without which the rest is theatre:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date -u +%Y-%m-%dT%H%MZ)
pg_dump --format=custom --no-owner "$DATABASE_URL" \
| gpg --batch --yes --encrypt --recipient "$BACKUP_GPG_KEY" \
| aws s3 cp - "s3://kehem-backups/db/${STAMP}.pgc"
find /var/backups/staging -type f -mtime +2 -delete
Schedule it with a systemd timer or cron on a different host from the database, apply a lifecycle policy of 30 days on the bucket, and keep the credentials for that bucket unable to delete objects. The 3-2-1 rule is still the right target at small scale: three copies, two media, one off-site.
Then prove it. Once a month, restore the latest dump into a scratch database and run a query against it. The first time you do this you will find something broken in your process — better to find it in a rehearsal than in an incident. Encryption matters too: a backup bucket holding customer data in plain text is a data-protection problem waiting to be discovered by someone else.
Secrets, servers and a few hard-won details
- Keep production secrets out of the repository and out of workflow YAML. GitHub Environments with required reviewers means a deploy to production waits for a human approval click, and the SSH key exists nowhere on a developer laptop.
- Deploy as a dedicated user with restricted sudo, not root. Restrict inbound SSH to your deploy key and enable fail2ban.
- Rotate the deploy key when someone leaves the team. A deployment key with a two-year lifespan is a liability dressed as convenience.
- Pin dependency versions and let Dependabot open update PRs. The upgrade you do monthly, in daylight, is a five-minute task; the one you postpone for two years is a rewrite.
- Watch what you ship with: Sentry for exceptions, one external uptime monitor hitting your
/healthz/and the login page, and Docker log rotation (max-size=10m,max-file=3) so a chatty container cannot fill the disk and take the server down at 3am.
What this actually costs
| Item | Monthly |
|---|---|
| VPS (4 vCPU / 8GB, or a €5–7 entry box for a small app) | $6–24 |
| Object storage for backups (a few GB) | < $1 |
| Container registry (GitHub Container Registry) | $0 |
| GitHub Actions (included minutes, self-hosted for heavy builds) | $0–4 |
| Uptime monitoring + error tracking (free tiers) | $0 |
| Total | well under $50 |
Common mistakes
- Deploying
latestand therefore having no idea what is actually running. - Running migrations after the new code starts serving requests.
- Testing on SQLite, deploying on PostgreSQL.
- Storing secrets in
.envfiles committed "just for staging." - Backing up nightly for two years and never once restoring.
- Root SSH access with a shared password and no key rotation.
- Skipping branch protection because "we all trust each other." Trust is not a rollback strategy.
A checklist for the first afternoon
- Write the multi-stage
Dockerfileand a/healthz/endpoint. - Add the CI workflow; turn on branch protection requiring it.
- Create the registry, push an image tagged with the commit SHA.
- Put Compose on the server behind Nginx with TLS.
- Add the SSH deploy job with a manually approved production environment.
- Schedule the encrypted nightly backup and set a calendar reminder to restore it monthly.
- Add Sentry and an uptime monitor, then break something small on purpose and confirm you hear about it before your customers do.
That is a complete, professional delivery pipeline for a small team — visible end to end, cheap to run, and calm to operate. If you would rather focus on your product and have this done properly, KEHEM IT builds exactly these pipelines for growing teams.
Related reading: Automated testing is what these pipelines are protecting; technical debt covers the release friction this removes; and website maintenance covers the ongoing operational habits. Streamline your deployment pipelines and infrastructure with KEHEM IT.
Deploying by hand and holding your breath? Talk to us about a pipeline your small team can actually maintain.
Have a project in mind?
KEHEM designs and builds thoughtful websites, SaaS products, and business systems.