010 — Deployment Files Explained
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
- The Big Picture
- Dockerfiles (Building Images)
- docker-compose.yml (Local Development)
- GitHub Actions Workflow (CI/CD)
- VPS Production Files
- How Everything Connects
- 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:
- Dockerfiles tell Docker how to package each app into an image
- GitHub Actions workflow automatically builds those Dockerfiles and pushes images to GHCR
- VPS docker-compose.yml pulls those images and runs them as containers
- Caddyfile routes incoming web traffic to the correct container
- .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 /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:
| Line | What it does |
|---|---|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder | Start with Microsoft's .NET 10 SDK image. Name this stage "builder". |
WORKDIR /src | Set 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 restore | Download all NuGet package dependencies. |
RUN dotnet publish -c Release -o /app/publish | Compile the code in Release mode, output to /app/publish. |
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runner | Start 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 5010 | Metadata: this container uses port 5010. |
ENV ASPNETCORE_URLS=http://+:5010 | Configure 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
.dllfiles and the ASP.NET runtime - No source code, no SDK, no build tools ship to production
- The
COPY contracts/line is why the buildcontextin 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 /app/apps/web/.next/standalone ./
# Static assets and public files (not included in standalone)
COPY /app/apps/web/.next/static ./apps/web/.next/static
COPY /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
Line-by-line breakdown:
| Line | What it does |
|---|---|
FROM node:20-slim AS base | Lightweight Node.js 20 image. Named "base" for reuse. |
ENV PNPM_HOME="/pnpm" | Configure where pnpm stores its binaries. |
RUN corepack enable | Enable 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 --force | Install all dependencies. --force avoids platform compatibility errors. |
RUN pnpm turbo run build --filter=web | Use 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/static | Copy static assets (CSS, JS bundles). These aren't in standalone. |
COPY --from=builder /app/apps/web/public ./apps/web/public | Copy public files (favicon, images, etc.). |
CMD ["node", "apps/web/server.js"] | Start the Next.js production server. |
Key points:
- Next.js
standalonemode 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/standalonepreserves the monorepo folder structure soserver.jscan 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 /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:
| Line | What it does |
|---|---|
RUN pnpm turbo run build --filter=admin | Build the admin Vite app (produces static files in dist/). |
FROM caddy:2-alpine AS runner | Use Caddy as the web server (lightweight, ~40MB image). |
COPY --from=builder /app/apps/admin/dist ./admin | Copy the compiled static files into /srv/admin. |
COPY apps/admin/Caddyfile /etc/caddy/Caddyfile | Copy 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/adminso 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:
| Line | What it does |
|---|---|
image: postgres:17 | Use official PostgreSQL 17 from Docker Hub. |
restart: unless-stopped | Auto-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/DB | Environment variables that PostgreSQL reads on first start to create the user and database. |
volumes: postgres_data:/var/lib/postgresql/data | Store database files in a named volume so data survives container recreation. |
healthcheck | Tells Docker how to verify PostgreSQL is ready. Other services use condition: service_healthy to wait. |
depends_on: condition: service_healthy | Adminer 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.ymlthat 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/**"
| Concept | Meaning |
|---|---|
push: branches: ["main"] | Only trigger on pushes to main branch |
paths: [...] | Only trigger if the push modified files matching these patterns |
workflow_dispatch | Adds 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 (matchesapps/**) - 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 stillghcr.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
| Secret | Value | Used by |
|---|---|---|
CR_USER | Your GitHub username (e.g., alidev) | docker/login-action to authenticate with GHCR |
CR_PAT | Your PAT with write:packages scope | docker/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:
.envis 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:
| Variable | What it configures |
|---|---|
DATABASE_URL | Full connection string to DigitalOcean Managed PostgreSQL (includes host, port, credentials, SSL) |
ADMIN_EMAIL/PASSWORD/DISPLAY_NAME | The API seeds this admin account into the database on first boot |
ASPNETCORE_ENVIRONMENT | Tells .NET to use production settings (no dev error pages, etc.) |
CORS_ORIGINS | The API only accepts requests from this origin (prevents cross-site attacks) |
COOKIE_DOMAIN | Sets the Domain attribute on auth cookies so they work across /, /api, /admin |
REPO_USER / REPO_PASS | Watchtower 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:
| Concept | Explanation |
|---|---|
USERNAME.lazuar.dev { } | This entire block applies to requests for this domain |
handle /api/* { } | Pattern matching — /api/anything matches this rule |
reverse_proxy api:5010 | Forward the request to hostname api port 5010 |
api, web, admin | These are Docker container hostnames (from docker-compose service names) |
| Order matters | handle /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:
| Aspect | Local (docker-compose.yml in repo) | Production (~/kanban-prod/docker-compose.yml) |
|---|---|---|
| Purpose | Run database for development | Run the full production application |
| Database | Local PostgreSQL container | External managed database (DigitalOcean) |
| Images | Not used (you run apps natively) | Pre-built from GHCR |
| HTTPS | Not needed (localhost) | Caddy handles SSL automatically |
| Auto-update | Not needed | Watchtower polls for new images |
| Ports exposed | 5432, 8080 | 80, 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
| Config | Purpose |
|---|---|
/var/run/docker.sock mount | Gives Watchtower access to the Docker daemon — it can stop/start/pull containers |
REPO_USER / REPO_PASS | GHCR authentication (so it can check for new images on private packages) |
--interval 300 | Check for new images every 300 seconds (5 minutes) |
--cleanup | Delete 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:
| File | What to change |
|---|---|
Caddyfile | First line: newdomain.com { |
.env | CORS_ORIGINS=https://newdomain.com |
.env | COOKIE_DOMAIN=newdomain.com |
Then: docker compose down && docker compose up -d
7.2 Add an Environment Variable to the API
-
Add to
.env:MY_NEW_VAR=some_value -
Add to
docker-compose.ymlunderapi.environment:environment: - MY_NEW_VAR=${MY_NEW_VAR} -
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
| File | Where | Purpose | When Modified |
|---|---|---|---|
services/api-dotnet/Dockerfile | Repo | Recipe to build the API image | When build process changes |
apps/web/Dockerfile | Repo | Recipe to build the web image | When build process changes |
apps/admin/Dockerfile | Repo | Recipe to build the admin image | When build process changes |
docker-compose.yml | Repo root | Local dev database (PostgreSQL + Adminer) | When adding dev tools |
.github/workflows/deploy.yml | Repo | CI/CD automation (build → push to GHCR) | When changing CI/CD logic |
~/kanban-prod/.env | VPS only | Secrets and runtime config | When credentials or domain change |
~/kanban-prod/Caddyfile | VPS only | Traffic routing rules | When adding routes or changing domain |
~/kanban-prod/docker-compose.yml | VPS only | Production container orchestration | When adding/removing services |