$ yuktics v0.1

T3 — Build Things People See module 03.5 ~6–10 hrs

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

  • Docker
  • GitHub Actions
  • Vercel (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.dev and .app are 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:

  1. Docker — Best practices for writing Dockerfiles. docs · 30 min · the canon, written by Docker. Read the layer-caching and multi-stage sections twice.
  2. GitHub — Workflow syntax for GitHub Actions. docs · 30 min · reference. You will copy from it forever.
  3. Fly.io — Speedrun. docs · 20 min · the fastest path from container to public URL with a database attached.
  4. 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:

  1. Never commit a .env file. Add it to .gitignore on day one. Add a .env.example with the keys but no values.
  2. Validate on startup. A missing SESSION_SECRET should crash the process loudly, not silently generate one.
  3. Different values per environment. DATABASE_URL in 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 to cname.vercel-dns.com)
  • api.yourname.com → Fly (CNAME to your Fly app, then flyctl 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

  1. The Twelve-Factor App · 20 years old, still right. The shape every cloud-deployable app should have.
  2. Sysdig — container security best practices · once you’re past hello-world.
  3. Fly.io’s writing on Postgres clusters · for when one DB is no longer enough.
  4. GitHub Actions — Reusable workflows · once you have three projects and you’re copying the same YAML around.
  5. 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

  1. Why does a multi-stage Dockerfile produce a smaller image than a single-stage one? Walk through what gets discarded.
  2. What does .dockerignore actually do, and how does it affect build time on a project with a node_modules directory?
  3. 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?
  4. You committed a real .env to a public repo. Walk through the response steps in order.
  5. You own yourname.com at a registrar but DNS is at Cloudflare. Describe the records needed to point yourname.com at Vercel and api.yourname.com at 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.