010 — Deployment Files Explained

📖 This document explains every configuration file involved in deploying the Kanban application — from Dockerfiles that build images, to the CI/CD workflow that automates everything, to the VPS files that run the production stack.

Read this to understand what each file does and how they connect together.


Table of Contents

  1. The Big Picture
  2. Dockerfiles (Building Images)
  3. docker-compose.yml (Local Development)
  4. GitHub Actions Workflow (CI/CD)
  5. VPS Production Files
  6. How Everything Connects
  7. Common Modifications

1. The Big Picture

┌─────────────────────────────────────────────────────────────────────────┐
│                          YOUR CODEBASE                                    │
│                                                                          │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────┐  │
│  │ services/        │  │ apps/web/         │  │ apps/admin/          │  │
│  │   api-dotnet/    │  │   Dockerfile      │  │   Dockerfile         │  │
│  │   Dockerfile     │  │   (Next.js)       │  │   (Vite + Caddy)     │  │
│  │   (.NET API)     │  │                   │  │                      │  │
│  └────────┬─────────┘  └────────┬──────────┘  └────────┬─────────────┘  │
│           │                      │                       │               │
│           └──────────────────────┼───────────────────────┘               │
│                                  │                                        │
│                     .github/workflows/deploy.yml                          │
│                     (builds all 3 Dockerfiles)                            │
│                                  │                                        │
└──────────────────────────────────┼────────────────────────────────────────┘
                                   │
                          GitHub Actions builds
                          & pushes to GHCR
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                           YOUR VPS                                        │
│                                                                          │
│  ~/kanban-prod/                                                          │
│  ├── .env                 ← secrets & config                             │
│  ├── Caddyfile            ← routing rules (domain → containers)          │
│  └── docker-compose.yml   ← pulls images from GHCR, runs 5 containers   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

The flow in plain English:

  1. Dockerfiles tell Docker how to package each app into an image
  2. GitHub Actions workflow automatically builds those Dockerfiles and pushes images to GHCR
  3. VPS docker-compose.yml pulls those images and runs them as containers
  4. Caddyfile routes incoming web traffic to the correct container
  5. .env provides secrets (database credentials, tokens) to the containers

2. Dockerfiles (Building Images)

Each application has its own Dockerfile. All three use multi-stage builds — a pattern where you use a large image to build the app, then copy only the output into a tiny production image.

Why Multi-Stage Builds?

WITHOUT multi-stage:
  SDK + source code + build tools + output = 1.5 GB image 😱

WITH multi-stage:
  Stage 1 (builder): SDK + source → compile → output files
  Stage 2 (runner):  Tiny runtime + output files only = 100-200 MB image ✅

2.1 API Dockerfile (.NET)

File: services/api-dotnet/Dockerfile

# ═══════════════════════════════════════════════════════════════
# STAGE 1: BUILD
# Uses the full .NET SDK (large, ~800MB) to compile the code
# ═══════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /src

# Copy source code into the build container
# We need both the API code AND the shared contracts library
COPY services/api-dotnet/ services/api-dotnet/
COPY contracts/ contracts/

# Move into the API project directory
WORKDIR /src/services/api-dotnet

# Restore NuGet packages (dependencies)
RUN dotnet restore

# Compile and publish a Release build to /app/publish
RUN dotnet publish -c Release -o /app/publish

# ═══════════════════════════════════════════════════════════════
# STAGE 2: RUN
# Uses only the ASP.NET runtime (small, ~200MB)
# No SDK, no source code, no build tools
# ═══════════════════════════════════════════════════════════════
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runner
WORKDIR /app

# Copy ONLY the compiled output from stage 1
COPY --from=builder /app/publish .

# Document that this container listens on port 5010
EXPOSE 5010

# Tell ASP.NET to listen on port 5010 (all interfaces)
ENV ASPNETCORE_URLS=http://+:5010

# Start the application
ENTRYPOINT ["dotnet", "api-dotnet.dll"]

Line-by-line breakdown:

LineWhat it does
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builderStart with Microsoft's .NET 10 SDK image. Name this stage "builder".
WORKDIR /srcSet the working directory inside the container to /src.
COPY services/api-dotnet/ services/api-dotnet/Copy the API source code from your repo into the container.
COPY contracts/ contracts/Copy the shared contracts (API models, DTOs) — the API depends on these.
RUN dotnet restoreDownload all NuGet package dependencies.
RUN dotnet publish -c Release -o /app/publishCompile the code in Release mode, output to /app/publish.
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runnerStart a NEW stage with only the ASP.NET runtime (no SDK).
COPY --from=builder /app/publish .Copy the compiled output from the builder stage into this clean image.
EXPOSE 5010Metadata: this container uses port 5010.
ENV ASPNETCORE_URLS=http://+:5010Configure ASP.NET to listen on port 5010.
ENTRYPOINT ["dotnet", "api-dotnet.dll"]When the container starts, run the compiled API.

Key points:

  • The final image contains only the compiled .dll files and the ASP.NET runtime
  • No source code, no SDK, no build tools ship to production
  • The COPY contracts/ line is why the build context in the workflow is . (repo root) — the Dockerfile needs files outside its own directory

2.2 Web Dockerfile (Next.js)

File: apps/web/Dockerfile

# ═══════════════════════════════════════════════════════════════
# BASE: Shared Node.js setup (reused by builder and runner)
# ═══════════════════════════════════════════════════════════════
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

# ═══════════════════════════════════════════════════════════════
# STAGE 1: BUILD
# Installs ALL dependencies, builds the Next.js app
# ═══════════════════════════════════════════════════════════════
FROM base AS builder
WORKDIR /app

# Copy ENTIRE monorepo (needed for turborepo + shared packages)
COPY . .

# Install all dependencies (including devDependencies for building)
RUN pnpm install --force

# Build only the web app using Turborepo
# --filter=web means: build the "web" package and its dependencies
RUN pnpm turbo run build --filter=web

# ═══════════════════════════════════════════════════════════════
# STAGE 2: RUN
# Only the standalone Next.js output (minimal, no node_modules bloat)
# ═══════════════════════════════════════════════════════════════
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

# Next.js "standalone" output includes only necessary node_modules
COPY --from=builder /app/apps/web/.next/standalone ./

# Static assets and public files (not included in standalone)
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public

EXPOSE 3000
CMD ["node", "apps/web/server.js"]

Line-by-line breakdown:

LineWhat it does
FROM node:20-slim AS baseLightweight Node.js 20 image. Named "base" for reuse.
ENV PNPM_HOME="/pnpm"Configure where pnpm stores its binaries.
RUN corepack enableEnable pnpm via Node.js corepack (built-in package manager manager).
COPY . .Copy the ENTIRE monorepo (turborepo needs all packages for dependency resolution).
RUN pnpm install --forceInstall all dependencies. --force avoids platform compatibility errors.
RUN pnpm turbo run build --filter=webUse Turborepo to build only the web app (and any packages it depends on).
COPY --from=builder /app/apps/web/.next/standalone ./Copy Next.js standalone output (includes a minimal node_modules + server.js).
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/staticCopy static assets (CSS, JS bundles). These aren't in standalone.
COPY --from=builder /app/apps/web/public ./apps/web/publicCopy public files (favicon, images, etc.).
CMD ["node", "apps/web/server.js"]Start the Next.js production server.

Key points:

  • Next.js standalone mode creates a self-contained server with only the dependencies it actually uses
  • The COPY . . in the builder copies the whole monorepo because Turborepo needs workspace context
  • The runner image is tiny (~100MB) despite the builder being large (~1.5GB)
  • Path apps/web/.next/standalone preserves the monorepo folder structure so server.js can find its files

2.3 Admin Dockerfile (Vite + Caddy)

File: apps/admin/Dockerfile

# ═══════════════════════════════════════════════════════════════
# STAGE 1: BUILD
# Compiles the Vite/React app into static HTML/CSS/JS files
# ═══════════════════════════════════════════════════════════════
FROM node:20-slim AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /app
COPY . .
RUN pnpm install --force
RUN pnpm turbo run build --filter=admin

# ═══════════════════════════════════════════════════════════════
# STAGE 2: SERVE
# Caddy serves the static files (tiny Alpine-based image)
# ═══════════════════════════════════════════════════════════════
FROM caddy:2-alpine AS runner
WORKDIR /srv

# Copy built static files into Caddy's serving directory
COPY --from=builder /app/apps/admin/dist ./admin

# Copy the Caddyfile that configures how to serve the SPA
COPY apps/admin/Caddyfile /etc/caddy/Caddyfile

EXPOSE 80

Line-by-line breakdown:

LineWhat it does
RUN pnpm turbo run build --filter=adminBuild the admin Vite app (produces static files in dist/).
FROM caddy:2-alpine AS runnerUse Caddy as the web server (lightweight, ~40MB image).
COPY --from=builder /app/apps/admin/dist ./adminCopy the compiled static files into /srv/admin.
COPY apps/admin/Caddyfile /etc/caddy/CaddyfileCopy the admin-specific Caddyfile (handles SPA routing).

Key points:

  • Vite builds produce pure static files (HTML, CSS, JS) — no server needed
  • Caddy serves these files and handles SPA client-side routing (all paths → index.html)
  • The files land in /srv/admin so the URL path /admin/ maps correctly
  • This is NOT the same Caddyfile as the gateway — this is an internal Caddyfile for serving the admin SPA within its container

Why Caddy for static files instead of nginx?

  • Simpler configuration (the Caddyfile is ~5 lines)
  • Handles SPA routing without extra config
  • Same technology as our gateway (consistency)
  • Much smaller config than equivalent nginx

2.4 Why the Build Context is the Repo Root

Notice in the GitHub workflow, all three builds use context: . (the repo root):

- name: Build and Push API
  uses: docker/build-push-action@v5
  with:
    context: .                              # ← repo root
    file: services/api-dotnet/Dockerfile    # ← but specific Dockerfile

Why? Because our Dockerfiles use COPY commands that reference files outside their own directory:

repo root (context: .)
├── contracts/              ← API needs this
├── packages/               ← Web and Admin need this
├── services/api-dotnet/
│   └── Dockerfile          ← COPY contracts/ (goes UP to repo root)
├── apps/web/
│   └── Dockerfile          ← COPY . . (needs entire monorepo)
└── apps/admin/
    └── Dockerfile          ← COPY . . (needs entire monorepo)

If we used context: services/api-dotnet/, the Dockerfile couldn't access contracts/ because Docker only sends the context directory to the build daemon.


3. docker-compose.yml (Local Development)

File: docker-compose.yml (in repo root)

This file is for local development only — it runs PostgreSQL and Adminer on your laptop so you can develop the app.

services:
  # ─────────────────────────────────────────────────────────
  # PostgreSQL — Local development database
  # ─────────────────────────────────────────────────────────
  postgres:
    image: postgres:17               # Official PostgreSQL 17 image
    container_name: kanban-postgres
    restart: unless-stopped          # Restart if it crashes (but not if you manually stop it)
    ports:
      - "5432:5432"                  # Expose on host port 5432 (standard PostgreSQL port)
    environment:
      POSTGRES_USER: kanban          # Database username
      POSTGRES_PASSWORD: kanban_dev  # Database password
      POSTGRES_DB: kanban            # Database name (created automatically)
    volumes:
      - postgres_data:/var/lib/postgresql/data    # Persist data between container restarts
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U kanban -d kanban"]  # Check if DB is accepting connections
      interval: 5s                   # Check every 5 seconds
      timeout: 5s                    # Fail if no response in 5 seconds
      retries: 5                     # Unhealthy after 5 consecutive failures

  # ─────────────────────────────────────────────────────────
  # Adminer — Web-based database GUI
  # ─────────────────────────────────────────────────────────
  adminer:
    image: adminer:latest
    container_name: kanban-adminer
    restart: unless-stopped
    ports:
      - "8080:8080"                  # Access at http://localhost:8080
    depends_on:
      postgres:
        condition: service_healthy   # Only start AFTER postgres is healthy

# Named volume — survives docker compose down (but NOT docker compose down -v)
volumes:
  postgres_data:

Line-by-line breakdown:

LineWhat it does
image: postgres:17Use official PostgreSQL 17 from Docker Hub.
restart: unless-stoppedAuto-restart on crash. Won't restart if you ran docker compose stop.
ports: - "5432:5432"Map host port 5432 to container port 5432. Your API connects to localhost:5432.
POSTGRES_USER/PASSWORD/DBEnvironment variables that PostgreSQL reads on first start to create the user and database.
volumes: postgres_data:/var/lib/postgresql/dataStore database files in a named volume so data survives container recreation.
healthcheckTells Docker how to verify PostgreSQL is ready. Other services use condition: service_healthy to wait.
depends_on: condition: service_healthyAdminer won't start until PostgreSQL passes its healthcheck.

How you use this locally:

# Start the database
docker compose up -d

# Your API (running via dotnet run or IDE) connects to:
# Host=localhost;Port=5432;Database=kanban;Username=kanban;Password=kanban_dev

# View database in browser:
# http://localhost:8080 → System: PostgreSQL, Server: postgres, User: kanban, Password: kanban_dev

# Stop (keep data)
docker compose down

# Stop and DELETE all data
docker compose down -v

📌 This file is NOT used on the VPS. The VPS has its own docker-compose.yml that runs the full production stack.


4. GitHub Actions Workflow (CI/CD)

File: .github/workflows/deploy.yml

This workflow automatically builds Docker images and pushes them to GHCR whenever you push code to main.

4.1 Complete Workflow with Annotations

# ═══════════════════════════════════════════════════════════════
# WORKFLOW METADATA
# ═══════════════════════════════════════════════════════════════
name: Build and Push Docker Images

# ─── TRIGGERS ────────────────────────────────────────────────
on:
  push:
    branches: ["main"]               # Only run when pushing to main
    paths:                           # Only run if THESE files changed:
      - "apps/**"                    #   Any frontend app
      - "services/**"                #   Any backend service
      - "contracts/**"               #   Shared API contracts
      - "packages/**"                #   Shared packages/libraries
      - "docker-compose.yml"         #   Docker config
      - "docker-compose.prod.yml"    #   Production Docker config
      - "gateway/**"                 #   Gateway config
      - ".github/workflows/deploy.yml"  # This workflow itself
  workflow_dispatch:                  # Allow manual triggering via GitHub UI button

# ─── CONCURRENCY ─────────────────────────────────────────────
# If a new push happens while a build is running, cancel the old one
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# ─── GLOBAL VARIABLES ────────────────────────────────────────
env:
  REGISTRY: ghcr.io
  # github.repository = "USERNAME/bootcamp"
  # This becomes the base path for all image tags
  IMAGE_NAME: ${{ github.repository }}

# ═══════════════════════════════════════════════════════════════
# JOBS (run in parallel unless they depend on each other)
# ═══════════════════════════════════════════════════════════════
jobs:
  # ────────────────────────────────────────────────────────────
  # JOB 1: Build .NET API image
  # ────────────────────────────────────────────────────────────
  api:
    runs-on: ubuntu-latest           # Run on GitHub's Ubuntu VM
    permissions:
      contents: read                 # Can read repo code
      packages: write                # Can push to GHCR
    steps:
      # Step 1: Download the code
      - name: Checkout repository
        uses: actions/checkout@v4

      # Step 2: Check if API-related files actually changed
      - name: Filter Changes
        uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            src:
              - 'services/api-dotnet/**'   # API source code
              - 'contracts/**'              # Shared contracts (API depends on these)

      # Step 3: Set up Docker Buildx (advanced builder with caching)
      - name: Set up Docker Buildx
        if: steps.changes.outputs.src == 'true'    # Skip if no relevant changes
        uses: docker/setup-buildx-action@v3

      # Step 4: Authenticate with GHCR
      - name: Log in to GHCR
        if: steps.changes.outputs.src == 'true'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}            # ghcr.io
          username: ${{ secrets.CR_USER }}          # Your GitHub username (from repo secrets)
          password: ${{ secrets.CR_PAT }}           # Your PAT (from repo secrets)

      # Step 5: Build the Docker image and push to GHCR
      - name: Build and Push API
        if: steps.changes.outputs.src == 'true'
        uses: docker/build-push-action@v5
        with:
          context: .                               # Build context = repo root
          file: services/api-dotnet/Dockerfile     # Which Dockerfile to use
          push: true                               # Push to registry (not just build locally)
          platforms: linux/amd64                    # Target platform (VPS architecture)
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/api:latest
          # ↑ Results in: ghcr.io/USERNAME/bootcamp/api:latest
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
          cache-from: type=gha                     # Pull cache from GitHub Actions cache
          cache-to: type=gha,mode=max              # Save cache for next build

  # ────────────────────────────────────────────────────────────
  # JOB 2: Build Next.js Web image
  # ────────────────────────────────────────────────────────────
  web:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Filter Changes
        uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            src:
              - 'apps/web/**'          # Web app source
              - 'packages/**'          # Shared packages (web depends on these)
              - 'contracts/**'         # Shared contracts

      - name: Set up Docker Buildx
        if: steps.changes.outputs.src == 'true'
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        if: steps.changes.outputs.src == 'true'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.CR_USER }}
          password: ${{ secrets.CR_PAT }}

      - name: Build and Push Web
        if: steps.changes.outputs.src == 'true'
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/web/Dockerfile
          push: true
          platforms: linux/amd64
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/web:latest
          # ↑ Results in: ghcr.io/USERNAME/bootcamp/web:latest
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ────────────────────────────────────────────────────────────
  # JOB 3: Build Vite Admin image
  # ────────────────────────────────────────────────────────────
  admin:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Filter Changes
        uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            src:
              - 'apps/admin/**'        # Admin app source
              - 'packages/**'          # Shared packages
              - 'contracts/**'         # Shared contracts

      - name: Set up Docker Buildx
        if: steps.changes.outputs.src == 'true'
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        if: steps.changes.outputs.src == 'true'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.CR_USER }}
          password: ${{ secrets.CR_PAT }}

      - name: Build and Push Admin
        if: steps.changes.outputs.src == 'true'
        uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/admin/Dockerfile
          push: true
          platforms: linux/amd64
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/admin:latest
          # ↑ Results in: ghcr.io/USERNAME/bootcamp/admin:latest
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

4.2 Key Concepts Explained

Triggers (on:)

on:
  push:
    branches: ["main"]
    paths:
      - "apps/**"
      - "services/**"
ConceptMeaning
push: branches: ["main"]Only trigger on pushes to main branch
paths: [...]Only trigger if the push modified files matching these patterns
workflow_dispatchAdds a "Run workflow" button in the GitHub Actions UI

Examples:

  • Push that only changes README.md → ❌ No build (not in paths list)
  • Push that changes apps/web/src/page.tsx → ✅ Build triggers (matches apps/**)
  • Push that changes contracts/types.ts → ✅ All three jobs trigger (matches filters for all three)

Concurrency

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

If you push twice quickly:

  • First push starts building
  • Second push arrives → cancels the first build → starts fresh

This prevents wasting CI minutes on builds that will be immediately outdated.

The if Condition (Second-Level Filtering)

- name: Filter Changes
  uses: dorny/paths-filter@v2
  id: changes
  with:
    filters: |
      src:
        - 'services/api-dotnet/**'
        - 'contracts/**'

- name: Build and Push API
  if: steps.changes.outputs.src == 'true'   # ← Only build if these files changed

Why two levels of filtering?

The top-level paths: triggers the workflow, but ALL three jobs still start. The dorny/paths-filter within each job does a second, more specific check:

Push changes: apps/web/src/page.tsx

Workflow triggers: YES (matches "apps/**" in top-level paths)

  Job: api   → dorny filter: services/api-dotnet/** changed? NO  → skips build ⏭️
  Job: web   → dorny filter: apps/web/** changed? YES           → builds! ✅
  Job: admin → dorny filter: apps/admin/** changed? NO          → skips build ⏭️

This saves build time — only the affected image gets rebuilt.

Image Tags

tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/api:latest

Resolves to:

ghcr.io / username/bootcamp / api : latest
  │            │                │       │
  │            │                │       └── tag (version)
  │            │                └── image name (sub-path)
  │            └── repository (auto from github.repository)
  └── registry host

Example for user alidev:

ghcr.io/alidev/bootcamp/api:latest
ghcr.io/alidev/bootcamp/web:latest
ghcr.io/alidev/bootcamp/admin:latest

📌 GitHub converts usernames to lowercase in package paths. If your username is AliDev, the image path is still ghcr.io/alidev/bootcamp/....

Caching (cache-from / cache-to)

cache-from: type=gha
cache-to: type=gha,mode=max

Docker builds consist of layers. If a layer hasn't changed, it can be reused:

Layer 1: FROM node:20-slim         ← cached (never changes)
Layer 2: RUN corepack enable       ← cached (never changes)
Layer 3: COPY package.json .       ← cached IF package.json unchanged
Layer 4: RUN pnpm install          ← cached IF package.json unchanged
Layer 5: COPY . .                  ← REBUILDS (source code changed)
Layer 6: RUN pnpm turbo build      ← REBUILDS (depends on previous layer)

type=gha stores these cached layers in GitHub Actions' cache. Next build reuses unchanged layers, making builds much faster.

Platforms

platforms: linux/amd64

Your VPS runs on AMD64 (x86_64) architecture. If you're developing on an Apple Silicon Mac (ARM), the image must be built for the correct target architecture. linux/amd64 ensures the image runs on your VPS.

4.3 Required Secrets

These must be set at: https://github.com/USERNAME/bootcamp/settings/secrets/actions

SecretValueUsed by
CR_USERYour GitHub username (e.g., alidev)docker/login-action to authenticate with GHCR
CR_PATYour PAT with write:packages scopedocker/login-action to authorize push

5. VPS Production Files

These three files live on your VPS at ~/kanban-prod/. They tell Docker what to run and how to route traffic.

5.1 .env — Secrets & Configuration

File: ~/kanban-prod/.env

# ═══════════════════════════════════════════════════════════════
# DATABASE CONNECTION
# Format: .NET Npgsql connection string
# ═══════════════════════════════════════════════════════════════
DATABASE_URL=Host=db-postgresql-sgp1-12345-do-user-xxxxx-0.c.db.ondigitalocean.com;Port=25060;Database=defaultdb;Username=doadmin;Password=AVNS_xxxxxxxxxxxxx;SSL Mode=Require;Trust Server Certificate=true

# ═══════════════════════════════════════════════════════════════
# ADMIN SEED DATA
# The API creates this admin user on first startup
# ═══════════════════════════════════════════════════════════════
ADMIN_EMAIL=admin@kanban.local
ADMIN_PASSWORD=Admin123!
ADMIN_DISPLAY_NAME=System Admin

# ═══════════════════════════════════════════════════════════════
# ASP.NET CONFIGURATION
# ═══════════════════════════════════════════════════════════════
ASPNETCORE_ENVIRONMENT=Production

# ═══════════════════════════════════════════════════════════════
# CORS (Cross-Origin Resource Sharing)
# Which origins can make API requests
# Must match your actual domain (with https://)
# ═══════════════════════════════════════════════════════════════
CORS_ORIGINS=https://USERNAME.lazuar.dev

# ═══════════════════════════════════════════════════════════════
# COOKIE DOMAIN
# Allows authentication cookies to work across all paths
# (no https://, just the bare domain)
# ═══════════════════════════════════════════════════════════════
COOKIE_DOMAIN=USERNAME.lazuar.dev

# ═══════════════════════════════════════════════════════════════
# WATCHTOWER CREDENTIALS
# Used to authenticate with GHCR when polling for new images
# Same PAT you use for GitHub Actions (needs write:packages scope)
# ═══════════════════════════════════════════════════════════════
REPO_USER=USERNAME
REPO_PASS=ghp_your_token_here

How .env works with docker-compose:

Docker Compose automatically reads .env in the same directory and substitutes ${VARIABLE} placeholders:

# In docker-compose.yml:
environment:
  - DATABASE_URL=${DATABASE_URL}
  # ↑ Docker replaces this with the value from .env

Security notes:

  • .env is NEVER committed to git (it's in .gitignore)
  • It contains real passwords and tokens
  • Only exists on the VPS (you create it manually during setup)
  • If someone gets access to this file, they have your database password and GitHub token

Parameter breakdown:

VariableWhat it configures
DATABASE_URLFull connection string to DigitalOcean Managed PostgreSQL (includes host, port, credentials, SSL)
ADMIN_EMAIL/PASSWORD/DISPLAY_NAMEThe API seeds this admin account into the database on first boot
ASPNETCORE_ENVIRONMENTTells .NET to use production settings (no dev error pages, etc.)
CORS_ORIGINSThe API only accepts requests from this origin (prevents cross-site attacks)
COOKIE_DOMAINSets the Domain attribute on auth cookies so they work across /, /api, /admin
REPO_USER / REPO_PASSWatchtower uses these to authenticate with GHCR when checking for image updates

5.2 Caddyfile — Reverse Proxy & HTTPS

File: ~/kanban-prod/Caddyfile

USERNAME.lazuar.dev {
    # ─── RULE 1: API Traffic ────────────────────────────
    # Any request starting with /api/ goes to the .NET API container
    handle /api/* {
        reverse_proxy api:5010
    }

    # ─── RULE 2: WebSocket (SignalR) ────────────────────
    # Real-time communication hub
    handle /api/hubs/* {
        reverse_proxy api:5010
    }

    # ─── RULE 3: Admin Panel ───────────────────────────
    # Requests to /admin/ go to the admin container (Vite SPA served by Caddy)
    handle /admin/* {
        reverse_proxy admin:80
    }

    # ─── RULE 4: Everything Else ───────────────────────
    # All other requests go to the Next.js web app
    handle /* {
        reverse_proxy web:3000
    }
}

How this works:

Browser request: https://USERNAME.lazuar.dev/api/boards
                          │                    │
                          │                    └── path: /api/boards
                          └── domain: USERNAME.lazuar.dev

Caddy receives the request:
  1. Domain matches "USERNAME.lazuar.dev" block ✅
  2. Path /api/boards matches "handle /api/*" ✅
  3. Forward to container named "api" on port 5010

What Caddy does automatically:

  • HTTPS: Obtains and renews SSL certificates from Let's Encrypt (zero config!)
  • HTTP→HTTPS redirect: Any HTTP request is automatically redirected to HTTPS
  • Reverse proxy: Routes requests to the correct internal container based on URL path

Key concepts:

ConceptExplanation
USERNAME.lazuar.dev { }This entire block applies to requests for this domain
handle /api/* { }Pattern matching — /api/anything matches this rule
reverse_proxy api:5010Forward the request to hostname api port 5010
api, web, adminThese are Docker container hostnames (from docker-compose service names)
Order mattershandle /api/* is checked before handle /* (most specific first)

Why path-based routing?

Single domain:  USERNAME.lazuar.dev
                    │
        ┌───────────┼───────────────────┐
        │           │                   │
    /api/*       /admin/*             /*
        │           │                   │
    .NET API    Vite Admin        Next.js Web
   (port 5010)  (port 80)        (port 3000)

Benefits:

  • Only ONE DNS record needed
  • Only ONE SSL certificate needed
  • Cookies work across all paths (same domain)
  • Simpler than managing subdomains

5.3 docker-compose.yml — Production Stack

File: ~/kanban-prod/docker-compose.yml

services:
  # ═══════════════════════════════════════════════════════════
  # GATEWAY — The entry point for ALL web traffic
  # Handles HTTPS termination and routes to other containers
  # ═══════════════════════════════════════════════════════════
  gateway:
    image: caddy:2-alpine            # Caddy web server (Alpine = tiny image)
    container_name: kanban-gateway
    restart: always                  # Always restart (even after server reboot)
    ports:
      - "80:80"                      # HTTP (needed for Let's Encrypt + redirects)
      - "443:443"                    # HTTPS (production traffic)
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile   # Mount our routing config INTO the container
      - caddy_data:/data                    # SSL certificates persist here
      - caddy_config:/config                # Caddy runtime config
    depends_on:
      - api
      - web
      - admin

  # ═══════════════════════════════════════════════════════════
  # API — .NET Backend
  # Handles business logic, database operations, authentication
  # ═══════════════════════════════════════════════════════════
  api:
    image: ghcr.io/USERNAME/bootcamp/api:latest
    container_name: kanban-api
    restart: always
    environment:                     # All values come from .env file
      - DATABASE_URL=${DATABASE_URL}
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}
      - ADMIN_EMAIL=${ADMIN_EMAIL}
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - ADMIN_DISPLAY_NAME=${ADMIN_DISPLAY_NAME}
      - CORS_ORIGINS=${CORS_ORIGINS}
      - COOKIE_DOMAIN=${COOKIE_DOMAIN}

  # ═══════════════════════════════════════════════════════════
  # WEB — Next.js Frontend
  # Server-side rendered React app (the main user interface)
  # ═══════════════════════════════════════════════════════════
  web:
    image: ghcr.io/USERNAME/bootcamp/web:latest
    container_name: kanban-web
    restart: always

  # ═══════════════════════════════════════════════════════════
  # ADMIN — Vite SPA (served by internal Caddy)
  # Admin dashboard for managing the application
  # ═══════════════════════════════════════════════════════════
  admin:
    image: ghcr.io/USERNAME/bootcamp/admin:latest
    container_name: kanban-admin
    restart: always

  # ═══════════════════════════════════════════════════════════
  # WATCHTOWER — Automatic image updater
  # Polls GHCR every 5 minutes for new images
  # If found: pulls new image → stops old container → starts new one
  # ═══════════════════════════════════════════════════════════
  watchtower:
    image: containrrr/watchtower
    container_name: kanban-watchtower
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock   # Access Docker daemon to manage containers
    environment:
      - REPO_USER=${REPO_USER}                      # GitHub username (for GHCR auth)
      - REPO_PASS=${REPO_PASS}                      # GitHub PAT (for GHCR auth)
    command: --interval 300 --cleanup
    #         │              │
    #         │              └── Remove old images after updating
    #         └── Check for updates every 300 seconds (5 minutes)

# ═══════════════════════════════════════════════════════════════
# NAMED VOLUMES — Persistent storage that survives container recreation
# ═══════════════════════════════════════════════════════════════
volumes:
  caddy_data:      # SSL certificates + ACME account info
  caddy_config:    # Caddy auto-generated config

Key differences from the local dev docker-compose:

AspectLocal (docker-compose.yml in repo)Production (~/kanban-prod/docker-compose.yml)
PurposeRun database for developmentRun the full production application
DatabaseLocal PostgreSQL containerExternal managed database (DigitalOcean)
ImagesNot used (you run apps natively)Pre-built from GHCR
HTTPSNot needed (localhost)Caddy handles SSL automatically
Auto-updateNot neededWatchtower polls for new images
Ports exposed5432, 808080, 443 only

How containers communicate:

Docker creates an internal network for all services in this compose file.
Each service can reach others by SERVICE NAME:

gateway → "api:5010"     (not localhost:5010!)
gateway → "web:3000"     (not localhost:3000!)
gateway → "admin:80"     (not localhost:80!)
api     → external DB    (via DATABASE_URL with internet hostname)

Why no ports: on api, web, admin?

api:
  image: ghcr.io/USERNAME/bootcamp/api:latest
  # NO ports: section!

These containers are NOT directly accessible from the internet. Only the gateway exposes ports 80/443. Traffic flow:

Internet → :80/:443 → gateway (Caddy) → internal network → api/web/admin
                                              ↑
                                   NOT accessible from internet directly

This is more secure — only Caddy faces the public internet.

Watchtower explained:

watchtower:
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock
  command: --interval 300 --cleanup
ConfigPurpose
/var/run/docker.sock mountGives Watchtower access to the Docker daemon — it can stop/start/pull containers
REPO_USER / REPO_PASSGHCR authentication (so it can check for new images on private packages)
--interval 300Check for new images every 300 seconds (5 minutes)
--cleanupDelete the old image after successfully updating to a new one (saves disk space)

What Watchtower does every 5 minutes:

1. List all running containers
2. For each container, check GHCR: "is there a newer version of this image?"
3. If YES:
   a. Pull the new image
   b. Stop the old container
   c. Start a new container with the same settings but new image
   d. Remove the old image (--cleanup)
4. If NO: do nothing, check again in 5 minutes

6. How Everything Connects

6.1 The Complete Pipeline (Push to Live)

┌─────────────────────────────────────────────────────────────────────┐
│ STEP 1: You push code                                                │
│                                                                      │
│   git push origin main                                               │
│   (changes to apps/web/src/page.tsx)                                 │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│ STEP 2: GitHub Actions triggers                                      │
│                                                                      │
│   Workflow sees: apps/** changed → starts all 3 jobs                 │
│   Job "web": dorny/paths-filter → apps/web/** changed → BUILD ✅     │
│   Job "api": dorny/paths-filter → services/api/** changed? → NO ⏭️   │
│   Job "admin": dorny/paths-filter → apps/admin/** changed? → NO ⏭️   │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│ STEP 3: Docker build runs (in GitHub's cloud)                        │
│                                                                      │
│   1. FROM node:20-slim AS base          ← cached layer (instant)     │
│   2. RUN corepack enable                ← cached layer (instant)     │
│   3. COPY . .                           ← new layer (code changed)   │
│   4. RUN pnpm install                   ← may be cached if no new deps│
│   5. RUN pnpm turbo run build --filter=web  ← rebuilds              │
│   6. FROM base AS runner                ← cached                     │
│   7. COPY --from=builder ...            ← new (built output)        │
│                                                                      │
│   Result: ghcr.io/USERNAME/bootcamp/web:latest (new SHA)             │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│ STEP 4: Image pushed to GHCR                                         │
│                                                                      │
│   ghcr.io/USERNAME/bootcamp/web:latest                               │
│   SHA: sha256:abc123... (new)                                        │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│ STEP 5: Watchtower detects the update (within 5 minutes)             │
│                                                                      │
│   Watchtower: "checking ghcr.io/USERNAME/bootcamp/web:latest..."     │
│   Watchtower: "local SHA: sha256:old999... ≠ remote SHA: sha256:abc123"│
│   Watchtower: "pulling new image..."                                 │
│   Watchtower: "stopping kanban-web..."                               │
│   Watchtower: "starting kanban-web with new image..."                │
│   Watchtower: "removing old image (--cleanup)"                       │
│                                                                      │
├─────────────────────────────────────────────────────────────────────┤
│ STEP 6: User sees the update                                         │
│                                                                      │
│   Browser → https://USERNAME.lazuar.dev → Caddy → new web container  │
│   🎉 Change is live!                                                  │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Total time: ~5-10 minutes (build) + 0-5 minutes (Watchtower poll) = ~5-15 min

6.2 Request Flow (User Visits the Site)

User types: https://USERNAME.lazuar.dev/api/boards

1. DNS Resolution
   Browser → Cloudflare DNS → "USERNAME.lazuar.dev = 167.71.x.x"

2. TLS Handshake
   Browser → VPS port 443 → Caddy presents SSL certificate
   (Certificate auto-obtained from Let's Encrypt)

3. Caddy Routing
   Caddy receives: GET /api/boards
   Matches: "handle /api/*" → reverse_proxy api:5010

4. Internal Network
   Caddy → Docker network → kanban-api container port 5010

5. API Processing
   .NET API receives request → queries database → returns JSON

6. Response Path
   API → Caddy → encrypted → Browser

┌─────────┐      ┌──────────┐      ┌────────────┐      ┌──────────┐
│ Browser │─────▶│  Caddy   │─────▶│  .NET API  │─────▶│   DB     │
│         │◀─────│ (gateway)│◀─────│ (kanban-api)│◀─────│(managed) │
└─────────┘      └──────────┘      └────────────┘      └──────────┘
    HTTPS          port 443          port 5010           port 25060
   (public)        (public)         (internal)          (external)

6.3 File Relationship Map

YOUR REPO (source code)                    YOUR VPS (running services)
═══════════════════════                    ═══════════════════════════

.github/workflows/deploy.yml ─────────┐
  │ uses secrets: CR_USER, CR_PAT      │
  │ builds these Dockerfiles:          │
  │                                    │
  ├─ services/api-dotnet/Dockerfile    │
  │    → ghcr.io/USERNAME/bootcamp/api:latest ──→  api container (port 5010)
  │                                    │             reads: DATABASE_URL, CORS, etc
  ├─ apps/web/Dockerfile               │
  │    → ghcr.io/USERNAME/bootcamp/web:latest ──→  web container (port 3000)
  │                                    │
  └─ apps/admin/Dockerfile             │
       → ghcr.io/USERNAME/bootcamp/admin:latest ─→  admin container (port 80)
                                       │
                                       │     ~/kanban-prod/
                                       │     ├── .env (secrets → containers)
                                       │     ├── Caddyfile → gateway container
                                       │     └── docker-compose.yml
                                       │           defines all 5 services
                                       │
                              GHCR ◄───┘     watchtower polls GHCR
                           (image store)          every 5 minutes

7. Common Modifications

7.1 Change Your Domain

Files to update:

FileWhat to change
CaddyfileFirst line: newdomain.com {
.envCORS_ORIGINS=https://newdomain.com
.envCOOKIE_DOMAIN=newdomain.com

Then: docker compose down && docker compose up -d

7.2 Add an Environment Variable to the API

  1. Add to .env:

    MY_NEW_VAR=some_value
    
  2. Add to docker-compose.yml under api.environment:

    environment:
      - MY_NEW_VAR=${MY_NEW_VAR}
    
  3. Restart: docker compose down && docker compose up -d

7.3 Change Watchtower Poll Interval

In docker-compose.yml:

command: --interval 60 --cleanup    # Check every 60 seconds (1 minute)

7.4 Add a New Path in Caddy

To route /docs/* to a new container:

USERNAME.lazuar.dev {
    handle /docs/* {
        reverse_proxy docs:8080
    }

    # ... existing rules ...
}

7.5 Disable Watchtower (Manual Updates Only)

Remove the watchtower service from docker-compose.yml, then update manually:

docker compose pull
docker compose up -d

7.6 Change Image Paths (e.g., Username Change)

If you change your GitHub username or move the repo:

Update docker-compose.yml — change all three image lines:

api:
  image: ghcr.io/NEW_USERNAME/bootcamp/api:latest
web:
  image: ghcr.io/NEW_USERNAME/bootcamp/web:latest
admin:
  image: ghcr.io/NEW_USERNAME/bootcamp/admin:latest

Also update REPO_USER in .env, then:

docker compose down
echo "ghp_TOKEN" | docker login ghcr.io -u NEW_USERNAME --password-stdin
docker compose pull
docker compose up -d

Quick Reference: Which File Does What

FileWherePurposeWhen Modified
services/api-dotnet/DockerfileRepoRecipe to build the API imageWhen build process changes
apps/web/DockerfileRepoRecipe to build the web imageWhen build process changes
apps/admin/DockerfileRepoRecipe to build the admin imageWhen build process changes
docker-compose.ymlRepo rootLocal dev database (PostgreSQL + Adminer)When adding dev tools
.github/workflows/deploy.ymlRepoCI/CD automation (build → push to GHCR)When changing CI/CD logic
~/kanban-prod/.envVPS onlySecrets and runtime configWhen credentials or domain change
~/kanban-prod/CaddyfileVPS onlyTraffic routing rulesWhen adding routes or changing domain
~/kanban-prod/docker-compose.ymlVPS onlyProduction container orchestrationWhen adding/removing services
Built with LogoFlowershow