From 5c1ea12fc259c991229936f1b341af839ff9596a Mon Sep 17 00:00:00 2001 From: Martin Svrcina Date: Sat, 21 Mar 2026 02:43:21 +0100 Subject: [PATCH] Add Docker deployment implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-21-docker-deployment.md | 495 ++++++++++++++++++ .../2026-03-21-docker-deployment-design.md | 4 +- 2 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-21-docker-deployment.md diff --git a/docs/superpowers/plans/2026-03-21-docker-deployment.md b/docs/superpowers/plans/2026-03-21-docker-deployment.md new file mode 100644 index 0000000..6886f04 --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-docker-deployment.md @@ -0,0 +1,495 @@ +# Docker Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deploy BudgetApp to a home server via two Docker containers (API + frontend) behind Nginx Proxy Manager. + +**Architecture:** A `budgetapp-api` container runs the .NET 8 ASP.NET Core API on port 8080. A `budgetapp-web` container runs nginx serving the Vue 3 static build on host port 3100, and internally proxies `/api/` requests to the API container over a shared Docker bridge network. Nginx Proxy Manager on the host proxies inbound traffic to port 3100. + +**Tech Stack:** .NET 8 / ASP.NET Core, Vue 3 / Vite / TypeScript, nginx, Docker / docker-compose + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `BudgetApp.Web/src/backend/ApiConnector.ts` | Modify | Remove hardcoded localhost URL, use relative path | +| `BudgetApp.Api/Program.cs` | Modify | Guard `UseHttpsRedirection()` for non-Production only | +| `.dockerignore` | Create | Exclude `bin/`, `obj/`, `docs/`, Web artifacts from API build context | +| `BudgetApp.Web/.dockerignore` | Create | Exclude `node_modules/`, `dist/` from web build context | +| `Dockerfile.api` | Create | Multi-stage .NET 8 build → runtime image | +| `BudgetApp.Web/Dockerfile.web` | Create | Multi-stage Node/Vite build → nginx runtime image | +| `BudgetApp.Web/nginx.web.conf` | Create | nginx config: serve SPA static files + proxy `/api/` to API container | +| `docker-compose.yml` | Create | Orchestrate both containers, network, port mappings, env vars | +| `.env` | Create | Gitignored secrets file — DB connection string | + +--- + +## Task 1: Fix Frontend API URL + +**Files:** +- Modify: `BudgetApp.Web/src/backend/ApiConnector.ts:3` + +The hardcoded `https://localhost:7244/api/Transactions` is baked into the Vite bundle at build time. The browser would call `localhost:7244` instead of going through nginx. Change to a relative path so all calls go through the nginx proxy. + +- [ ] **Step 1: Update `BASE_URL` to a relative path** + +In `BudgetApp.Web/src/backend/ApiConnector.ts`, change line 3: + +```typescript +const BASE_URL = '/api/Transactions' +``` + +- [ ] **Step 2: Verify type-check passes** + +```bash +cd BudgetApp.Web && npm run type-check +``` + +Expected: no errors. + +- [ ] **Step 3: Verify dev build compiles** + +```bash +npm run build-only +``` + +Expected: `dist/` created with no errors. + +- [ ] **Step 4: Commit** + +```bash +cd .. +git add BudgetApp.Web/src/backend/ApiConnector.ts +git commit -m "fix: use relative API URL for Docker compatibility" +``` + +--- + +## Task 2: Guard HTTPS Redirection + +**Files:** +- Modify: `BudgetApp.Api/Program.cs:40` + +In Docker, TLS is terminated at Nginx Proxy Manager. The API container receives plain HTTP on port 8080. With `UseHttpsRedirection()` active, the nginx proxy call would receive a `307` redirect and fail. Wrap it to only apply outside Production. + +- [ ] **Step 1: Wrap `UseHttpsRedirection()` in a development-only guard** + +In `BudgetApp.Api/Program.cs`, replace line 40: + +```csharp +// Before: +app.UseHttpsRedirection(); + +// After: +if (!app.Environment.IsProduction()) +{ + app.UseHttpsRedirection(); +} +``` + +- [ ] **Step 2: Verify the project builds** + +```bash +dotnet build BudgetApp.Api/BudgetApp.Api.csproj +``` + +Expected: `Build succeeded.` + +- [ ] **Step 3: Commit** + +```bash +git add BudgetApp.Api/Program.cs +git commit -m "fix: disable HTTPS redirection in Production for Docker deployment" +``` + +--- + +## Task 3: Create `.dockerignore` Files + +**Files:** +- Create: `.dockerignore` +- Create: `BudgetApp.Web/.dockerignore` + +Without `.dockerignore`, Docker sends the entire working directory as the build context — including `node_modules/` (potentially hundreds of MB), `bin/`, `obj/`, and `dist/`. This slows builds and pollutes layers. The API and Web builds each need their own `.dockerignore` scoped to their respective build contexts. + +- [ ] **Step 1: Create root `.dockerignore` for the API build context** + +Create `.dockerignore` at solution root: + +``` +# .NET build outputs +**/bin/ +**/obj/ + +# Frontend (not needed for API build) +BudgetApp.Web/node_modules/ +BudgetApp.Web/dist/ + +# Docs and editor files +docs/ +.vs/ +.vscode/ +.idea/ +*.user + +# Git +.git/ +.gitignore +``` + +- [ ] **Step 2: Create `BudgetApp.Web/.dockerignore` for the web build context** + +Create `BudgetApp.Web/.dockerignore`: + +``` +node_modules/ +dist/ +dist-ssr/ +coverage/ +*.local +.eslintcache +*.tsbuildinfo +``` + +- [ ] **Step 3: Commit** + +```bash +git add .dockerignore BudgetApp.Web/.dockerignore +git commit -m "chore: add .dockerignore files for API and web build contexts" +``` + +--- + +## Task 4: Create `Dockerfile.api` + +**Files:** +- Create: `Dockerfile.api` + +Multi-stage build: SDK image restores and publishes the app; the smaller `aspnet` runtime image runs it. The build context must be the solution root because `BudgetApp.Api` references sibling projects (`BudgetApp.Storage`, `BudgetApp.Services`, `BudgetApp.PublicModels`, `BudgetApp.Enums`). + +- [ ] **Step 1: Create `Dockerfile.api` at solution root** + +```dockerfile +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy project files and restore (layer-cached separately for faster rebuilds) +COPY BudgetApp.Api/BudgetApp.Api.csproj BudgetApp.Api/ +COPY BudgetApp.Storage/BudgetApp.Storage.csproj BudgetApp.Storage/ +COPY BudgetApp.Services/BudgetApp.Services.csproj BudgetApp.Services/ +COPY BudgetApp.PublicModels/BudgetApp.PublicModels.csproj BudgetApp.PublicModels/ +COPY BudgetApp.Enums/BudgetApp.Enums.csproj BudgetApp.Enums/ +RUN dotnet restore BudgetApp.Api/BudgetApp.Api.csproj + +# Copy source and publish +COPY BudgetApp.Api/ BudgetApp.Api/ +COPY BudgetApp.Storage/ BudgetApp.Storage/ +COPY BudgetApp.Services/ BudgetApp.Services/ +COPY BudgetApp.PublicModels/ BudgetApp.PublicModels/ +COPY BudgetApp.Enums/ BudgetApp.Enums/ +RUN dotnet publish BudgetApp.Api/BudgetApp.Api.csproj -c Release -o /app/publish --no-restore + +# Stage 2: Runtime +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "BudgetApp.Api.dll"] +``` + +- [ ] **Step 2: Build the image to verify it compiles** + +Run from the solution root: + +```bash +docker build -f Dockerfile.api -t budgetapp-api:test . +``` + +Expected: `Successfully tagged budgetapp-api:test` with no errors. The build will pull the SDK image on first run (~200 MB). + +- [ ] **Step 3: Verify the container starts and `/ping` responds** + +```bash +docker run --rm -d \ + -p 8080:8080 \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e "ConnectionStrings__MainDatabase=Server=seth.local;User Id=budget;Password=;Database=budget" \ + --name budgetapp-api-test \ + budgetapp-api:test + +# Wait a few seconds, then test +sleep 3 +curl http://localhost:8080/ping +``` + +Expected: `{"ok":true,"time":"..."}` — confirms the API starts and connects to MySQL. + +- [ ] **Step 4: Stop and remove the test container** + +```bash +docker stop budgetapp-api-test +docker rmi budgetapp-api:test +``` + +- [ ] **Step 5: Commit** + +```bash +git add Dockerfile.api +git commit -m "feat: add multi-stage Dockerfile for .NET API" +``` + +--- + +## Task 5: Create `nginx.web.conf` and `Dockerfile.web` + +**Files:** +- Create: `BudgetApp.Web/nginx.web.conf` +- Create: `BudgetApp.Web/Dockerfile.web` + +The nginx config serves the Vue SPA (with `try_files` for Vue Router) and proxies `/api/` requests to the API container. Both controllers use `[Route("api/[controller]")]`, so the `/api/` prefix must be **preserved** — use `proxy_pass http://budgetapp-api:8080` with no trailing slash. + +- [ ] **Step 1: Create `BudgetApp.Web/nginx.web.conf`** + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy /api/ requests to the API container — prefix preserved + location /api/ { + proxy_pass http://budgetapp-api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Serve Vue SPA — fallback to index.html for client-side routing + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Step 2: Create `BudgetApp.Web/Dockerfile.web`** + +```dockerfile +# Stage 1: Build Vue app +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build-only + +# Stage 2: Serve with nginx +FROM nginx:alpine AS runtime +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.web.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +- [ ] **Step 3: Build the web image to verify it compiles** + +Run from the solution root: + +```bash +docker build -f BudgetApp.Web/Dockerfile.web -t budgetapp-web:test ./BudgetApp.Web +``` + +Expected: `Successfully tagged budgetapp-web:test`. + +- [ ] **Step 4: Verify the container serves the app** + +```bash +docker run --rm -d -p 3100:80 --name budgetapp-web-test budgetapp-web:test +sleep 2 +curl -I http://localhost:3100 +``` + +Expected: `HTTP/1.1 200 OK` with `Content-Type: text/html`. + +- [ ] **Step 5: Stop and remove the test container** + +```bash +docker stop budgetapp-web-test +docker rmi budgetapp-web:test +``` + +- [ ] **Step 6: Commit** + +```bash +git add BudgetApp.Web/nginx.web.conf BudgetApp.Web/Dockerfile.web +git commit -m "feat: add nginx config and Dockerfile for Vue frontend" +``` + +--- + +## Task 6: Create `docker-compose.yml` and `.env` + +**Files:** +- Create: `docker-compose.yml` +- Create: `.env` + +The compose file wires both containers together on a shared bridge network and injects the DB connection string from `.env`. The `.env` file is already gitignored by the root `.gitignore`. + +- [ ] **Step 1: Create `.env` with the DB connection string** + +Create `.env` at solution root (this file is gitignored — never commit it): + +``` +ConnectionStrings__MainDatabase=Server=seth.local;User Id=budget;Password=;Database=budget +``` + +- [ ] **Step 2: Create `docker-compose.yml` at solution root** + +```yaml +services: + budgetapp-api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "8080:8080" + environment: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__MainDatabase: ${ConnectionStrings__MainDatabase} + networks: + - budgetapp + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ping || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + + budgetapp-web: + build: + context: ./BudgetApp.Web + dockerfile: Dockerfile.web + ports: + - "3100:80" + networks: + - budgetapp + depends_on: + - budgetapp-api + restart: unless-stopped + +networks: + budgetapp: + driver: bridge +``` + +- [ ] **Step 3: Validate the compose file** + +```bash +docker-compose config +``` + +Expected: Resolved config printed with no errors. + +- [ ] **Step 4: Commit (`.env` is gitignored and will not be committed)** + +```bash +git add docker-compose.yml +git commit -m "feat: add docker-compose orchestration for full stack deployment" +``` + +--- + +## Task 7: Full Stack Integration Test + +Verify both containers start together, the API is reachable internally, and the frontend serves the app with working API calls. + +- [ ] **Step 1: Build and start all containers** + +```bash +docker-compose up -d --build +``` + +Expected: Both containers start. May take a few minutes on first build. + +- [ ] **Step 2: Check container status** + +```bash +docker-compose ps +``` + +Expected: Both `budgetapp-api` and `budgetapp-web` show as `running`. + +- [ ] **Step 3: Check API health via host port** + +```bash +curl http://localhost:8080/ping +``` + +Expected: `{"ok":true,"time":"..."}`. + +- [ ] **Step 4: Check frontend loads via host port** + +```bash +curl -I http://localhost:3100 +``` + +Expected: `HTTP/1.1 200 OK` with `Content-Type: text/html`. + +- [ ] **Step 5: Check API is reachable through frontend's nginx proxy** + +```bash +curl http://localhost:3100/api/Transactions/getTransactions +``` + +Expected: JSON response from the API (not a 502 Bad Gateway). + +- [ ] **Step 6: Check logs if anything fails** + +```bash +docker-compose logs budgetapp-api +docker-compose logs budgetapp-web +``` + +- [ ] **Step 7: Stop containers** + +```bash +docker-compose down +``` + +--- + +## Task 8: Push to Remote + +- [ ] **Step 1: Verify nothing sensitive was staged** + +```bash +git status +``` + +Confirm `.env` is NOT listed. Only committed files should appear. + +- [ ] **Step 2: Push to remote** + +```bash +git push +``` + +--- + +## NPM Setup (Manual — on the server) + +After `docker-compose up -d --build` runs on the server: + +1. Open Nginx Proxy Manager web UI +2. Add a new **Proxy Host**: + - **Domain Names:** `budget.yourdomain.com` (or your local DNS name) + - **Scheme:** `http` + - **Forward Hostname / IP:** your server's host IP + - **Forward Port:** `3100` + - Enable **Websockets Support** (optional, for Vite HMR if ever needed) +3. Add SSL certificate via Let's Encrypt if desired + +No second proxy host needed — `/api/` traffic is handled internally by the web container's nginx. diff --git a/docs/superpowers/specs/2026-03-21-docker-deployment-design.md b/docs/superpowers/specs/2026-03-21-docker-deployment-design.md index bd8c8b9..7722862 100644 --- a/docs/superpowers/specs/2026-03-21-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-03-21-docker-deployment-design.md @@ -27,7 +27,7 @@ Deploy BudgetApp to a home server running Nginx Proxy Manager (NPM) and an exist ``` - `budgetapp-web` exposes **port 3100** to host — NPM points here -- `budgetapp-web` nginx proxies `/api/` → `http://budgetapp-api:8080/` over internal Docker network. Note: the `/api/` prefix is stripped before forwarding (both `location` and `proxy_pass` end with `/`), so the backend must not expect `/api/` in its routes. +- `budgetapp-web` nginx proxies `/api/` → `http://budgetapp-api:8080` over internal Docker network. The `/api/` prefix is **preserved** (no trailing slash on `proxy_pass`) because both controllers use `[Route("api/[controller]")]`. - `budgetapp-api` exposes **port 8080** (optional, for direct access/debugging) - Both containers share a Docker bridge network named `budgetapp` - API connects to the existing MySQL at `seth.local` @@ -91,7 +91,7 @@ if (!app.Environment.IsProduction()) - **Build context:** `BudgetApp.Web/` - **Build output:** `dist/` copied into nginx image - **Runtime port:** 80 (mapped to host port 3100) -- **API routing:** nginx proxies `location /api/` → `http://budgetapp-api:8080/` (prefix stripped) +- **API routing:** nginx proxies `location /api/` → `http://budgetapp-api:8080` (prefix preserved — controllers use `[Route("api/[controller]")]`) - **SPA routing:** `try_files $uri $uri/ /index.html` for Vue Router - **Depends on:** `budgetapp-api`