Deploying and DevOps lite
Docker, GitHub Actions, Vercel/Fly/Railway. Putting a project on the internet so a recruiter can click it — without a Kubernetes detour.
Prerequisites
03.3
Stack
DockerGitHub ActionsVercel (or Fly.io / Railway / Render)Cloudflare for DNS
By the end of this module
- Write a multi-stage Dockerfile that produces an image under 200MB.
- Set up CI that runs lint, types, and tests on every push, and CD that deploys main automatically.
- Deploy a frontend to Vercel and a backend plus database to Fly or Railway, on a real custom domain with HTTPS.
- Manage secrets correctly without committing a single one to git.
A project that lives on localhost:3000 does not exist. It does not appear in your portfolio. It cannot be sent to a friend, shared in a Slack DM, or clicked by a recruiter. Until something has a URL with HTTPS, it is a draft.
This module is about the smallest possible infrastructure that gets a real project to a real URL with a real custom domain. Docker, a CI pipeline, a managed deploy target, and Cloudflare for DNS. Total moving parts: four. Total operating cost: usually zero on a small project. Time from git push to live: under two minutes, repeatable forever.
The single biggest opinionated take in this module: you do not need Kubernetes. You almost certainly will not need Kubernetes for the entire decade of your career as a frontend or full-stack developer at any startup of a sensible size. Kubernetes is the answer to a question you do not have. Reach for it the day you have a fleet of services across multiple teams, not before. In the meantime, Vercel, Fly, Railway, and Render solve the actual deploy problem with a fraction of the pager pain.
Set up
You’ll need:
- Docker Desktop installed and running.
- A GitHub account with the project from 03.3 pushed to a repo.
- A free account on whichever managed host you’ll use. Defaults below: Vercel for the frontend, Fly.io for the backend.
- A domain name. If you don’t own one,
yourname.devand.appare cheap and HTTPS-only by design. - Cloudflare for DNS (free tier).
# Verify
docker --version
gh --version
flyctl version # `brew install flyctl`
vercel --version # `pnpm dlx vercel --version`
If any of those fail, fix them now. The rest of the module assumes they exist.
Read these first
Four references, in order, then stop:
- Docker — Best practices for writing Dockerfiles. docs · 30 min · the canon, written by Docker. Read the layer-caching and multi-stage sections twice.
- GitHub — Workflow syntax for GitHub Actions. docs · 30 min · reference. You will copy from it forever.
- Fly.io — Speedrun. docs · 20 min · the fastest path from container to public URL with a database attached.
- Cloudflare — DNS records. docs · 15 min · so you stop being scared of A vs CNAME vs ALIAS.
You’ll be tempted to read about Terraform, Pulumi, Ansible, and Helm. Don’t, yet. None of them earn their weight on a project of this size, and the time you’d spend learning them is better spent shipping three more projects.
Step 1 — A Dockerfile that’s actually small
The first Dockerfile most students write is 1.5GB and includes a full Linux distribution, every dev dependency, and the source tree twice. Multi-stage builds fix this.
For a FastAPI backend:
# syntax=docker/dockerfile:1.7
# --- builder ---
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# --- runtime ---
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH"
# Non-root user
RUN useradd -r -u 1001 app
COPY --from=builder /app/.venv /app/.venv
COPY --chown=app:app . .
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
For a Next.js frontend, the official Next image guidance does most of the work:
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Set output: 'standalone' in next.config.js to get the trimmed runtime. Image size after this: roughly 120MB for Next, roughly 180MB for FastAPI. If yours is much bigger, you forgot to multi-stage.
A .dockerignore worth keeping:
.git
.github
.venv
node_modules
.next
__pycache__
*.pyc
.env
.env.*
README.md
tests
What .dockerignore actually does: it controls the build context Docker sends to the daemon. If you don’t have one, you ship your entire git history into every layer cache and slow every build by 10x.
Step 2 — Local dev with Docker Compose
Compose is for local dev, not production. It starts your app and its dependencies (Postgres, Redis) with one command.
# docker-compose.yml
services:
api:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgres://postgres:dev@db:5432/app
depends_on:
db:
condition: service_healthy
volumes:
- ./app:/app/app
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
volumes:
pgdata:
docker compose up --build
docker compose down # stop
docker compose down -v # nuke the volumes too
The volume mount on ./app gives you live reload during dev without rebuilding the image on every change. Don’t ship that mount to production.
Step 3 — CI that runs on every push
GitHub Actions earns its weight here. One workflow file gives you lint, types, tests, and build on every push:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: dev
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install uv && uv sync --frozen
- run: uv run ruff check .
- run: uv run mypy app
- run: uv run pytest --cov=app --cov-fail-under=80
env:
DATABASE_URL: postgres://postgres:dev@localhost:5432/postgres
For the Next.js side, swap setup-python for setup-node, run pnpm lint, pnpm typecheck, pnpm test, and pnpm build. If build succeeds in CI, you have proof that production will at least compile.
CD is the same idea, gated on the test job:
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
Now every push to main deploys, gated on tests passing. That is the entire CI/CD story for a small project, in 50 lines of YAML.
Step 4 — Frontend on Vercel
Vercel autodetects Next.js and gives you previews on every PR. Total setup:
pnpm dlx vercel link
pnpm dlx vercel deploy --prod
After that, every push to main deploys, every PR gets a preview URL, and HTTPS is automatic. Set environment variables in the Vercel dashboard or via:
pnpm dlx vercel env add DATABASE_URL production
Use Preview environments. Every PR gets its own URL. Send it to a friend or a designer for review before merging.
Step 5 — Backend on Fly.io
Fly runs your Docker image on a real machine close to your users, with a managed Postgres in one command:
flyctl launch # detects Dockerfile, generates fly.toml
flyctl postgres create # provisions a managed Postgres
flyctl postgres attach <pg-app> # sets DATABASE_URL on your app
flyctl secrets set SESSION_SECRET=$(openssl rand -hex 32)
flyctl deploy
A reasonable fly.toml for a small backend:
app = "todo-api"
primary_region = "iad"
[build]
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop" # save money during low traffic
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
A 256MB shared instance is enough for tens of thousands of requests a day on a modest API. Scale up only when the metrics tell you to.
Railway and Render are interchangeable replacements with friendlier dashboards and slightly different pricing. Pick one and stop comparing.
Step 6 — Environment variables and secrets
Three rules:
- Never commit a
.envfile. Add it to.gitignoreon day one. Add a.env.examplewith the keys but no values. - Validate on startup. A missing
SESSION_SECRETshould crash the process loudly, not silently generate one. - Different values per environment.
DATABASE_URLin dev points at your local Postgres. In prod it points at Fly’s. CI gets a third value.
# app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
session_secret: str
rate_limit_per_minute: int = 60
class Config:
env_file = ".env"
settings = Settings() # raises if env vars are missing
If a secret leaks (you committed a real .env, you posted a token in a Slack channel, you pushed to a public repo by accident): rotate it immediately. Do not assume nobody saw it. Bots scrape GitHub for AKIA* strings within seconds.
Step 7 — Domain and DNS
Buy a domain at Cloudflare Registrar, Namecheap, or Porkbun. Move DNS to Cloudflare regardless of the registrar — their dashboard is the best, free, and the proxy gives you free DDoS protection.
For the apps:
yourname.com→ Vercel (CNAME or ALIAS tocname.vercel-dns.com)api.yourname.com→ Fly (CNAME to your Fly app, thenflyctl certs add api.yourname.com)
Cloudflare proxy on, SSL/TLS mode “Full (strict)”. Both Vercel and Fly issue Let’s Encrypt certificates automatically; you do not manage certificate files yourself.
Test it:
curl -I https://yourname.com
curl -I https://api.yourname.com/healthz
If both return 200 with valid HTTPS, you’re shipped.
Going deeper
- The Twelve-Factor App · 20 years old, still right. The shape every cloud-deployable app should have.
- Sysdig — container security best practices · once you’re past hello-world.
- Fly.io’s writing on Postgres clusters · for when one DB is no longer enough.
- GitHub Actions — Reusable workflows · once you have three projects and you’re copying the same YAML around.
- Site Reliability Engineering · Google · the book to read once you have a paging service in production.
Skip “Kubernetes for beginners 2026.” If you’re not running 20-plus services across multiple teams, the answer is “later, and probably never.”
Checkpoints
- Why does a multi-stage Dockerfile produce a smaller image than a single-stage one? Walk through what gets discarded.
- What does
.dockerignoreactually do, and how does it affect build time on a project with anode_modulesdirectory? - Sketch a CI pipeline that runs lint, types, tests, and build on every PR. Where do you require checks to pass before allowing a merge to
main? - You committed a real
.envto a public repo. Walk through the response steps in order. - You own
yourname.comat a registrar but DNS is at Cloudflare. Describe the records needed to pointyourname.comat Vercel andapi.yourname.comat Fly.
When https://yourname.com and https://api.yourname.com both return real responses, move to 03.6 Mobile — where it actually matters, where you’ll learn when to extend this app to a real native client and when to leave it as a website.