test_workflow
Some checks failed
Backend ECR Deployment / build-and-push (push) Has been cancelled
Backend ECR Deployment / notify-deploy (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
Backend Modal Build / Build & Push via Modal (push) Has been cancelled

This commit is contained in:
DatTT127
2026-07-18 17:48:19 +07:00
parent 7d5c583475
commit 57a8bac1be
87 changed files with 36291 additions and 155 deletions

View File

@@ -0,0 +1,342 @@
# ECR Public CI/CD Setup Guide (with Caddy Reverse Proxy)
Complete setup for Lumina MSK Frontend: Gitea CI → Amazon ECR Public → Lightsail Deploy with Caddy (auto-HTTPS)
---
## Prerequisites
| Requirement | Version/Details |
|-------------|-----------------|
| AWS Account | With ECR Public access |
| Lightsail VM | 2 GB, Ubuntu 22.04+, Docker + Docker Compose installed |
| Gitea Server | Running on Lightsail (or accessible), Actions enabled |
| Domain | For HTTPS via Let's Encrypt (Caddy handles this automatically) |
---
## 1. Amazon ECR Public Setup
### 1.1 Create Public Registry
```bash
# AWS Console → ECR Public → Create registry
# Note the Registry Alias (e.g., vkist-pilot)
# Region: us-east-1 (only region for ECR Public)
```
### 1.2 Create Repository
```bash
aws ecr-public create-repository \
--repository-name lumina-frontend \
--region us-east-1
```
### 1.3 Create IAM User for CI
```bash
aws iam create-user --user-name gitea-ci-ecr-public
```
### 1.4 Attach Policy
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"ecr-public:InitiateLayerUpload",
"ecr-public:UploadLayerPart",
"ecr-public:CompleteLayerUpload",
"ecr-public:PutImage"
],
"Resource": "*"
}
]
}
```
```bash
aws iam put-user-policy \
--user-name gitea-ci-ecr-public \
--policy-name ECRPublicPush \
--policy-document file://ecr-public-push-policy.json
```
### 1.5 Create Access Keys
```bash
aws iam create-access-key --user-name gitea-ci-ecr-public
# Save: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
```
---
## 2. Gitea Secrets Configuration
Go to: **Repository → Settings → Secrets → Actions**
| Secret Name | Value |
|-------------|-------|
| `AWS_ACCESS_KEY_ID` | From step 1.5 |
| `AWS_SECRET_ACCESS_KEY` | From step 1.5 |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your registry alias (e.g., `vkist-pilot`) |
| `WEBHOOK_SECRET` | `openssl rand -hex 32` |
| `DEPLOY_WEBHOOK_URL` | `http://<lightsail-ip>:3333/deploy` |
---
## 3. Lightsail VM Setup
### 3.1 Install Docker + Compose
```bash
sudo apt update
sudo apt install -y docker.io docker-compose-plugin python3
sudo usermod -aG docker ubuntu
newgrp docker
```
### 3.2 Deploy Project Files
```bash
cd /opt
sudo git clone <your-gitea-repo-url> pilot-project
sudo chown -R ubuntu:ubuntu pilot-project
cd pilot-project
```
### 3.3 Configure Environment
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/env.example .env
# Edit .env with your values:
# ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
# WEBHOOK_SECRET=<same-as-gitea-secret>
```
### 3.4 Deploy Compose File
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/docker-compose.frontend.yaml .
# Create external network
docker network create pilot-network
```
### 3.5 Install Deploy Script + Webhook Listener
```bash
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-deploy.sh .
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-listener.py .
chmod +x webhook-deploy.sh
```
### 3.6 Install Systemd Service
```bash
sudo cp workspace/sprint_1_2/CODEBASE/deps/spec/gitea-webhook-listener.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable gitea-webhook-listener
sudo systemctl start gitea-webhook-listener
sudo systemctl status gitea-webhook-listener
```
### 3.7 Verify Webhook Listener
```bash
curl -X POST http://localhost:3333/deploy \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: sha256=$(echo -n '{}' | openssl dgst -sha256 -hmac "$(grep WEBHOOK_SECRET .env | cut -d= -f2)" | cut -d' ' -f2)" \
-d '{}'
# Should return "Event ignored" (no workflow completed)
```
---
## 4. Gitea Webhook Configuration
### 4.1 Add Webhook
**Repository → Settings → Webhooks → Add Webhook**
| Field | Value |
|-------|-------|
| Target URL | `http://<lightsail-ip>:3333/deploy` |
| Secret | `<same WEBHOOK_SECRET from step 2>` |
| Events | ✅ Workflow Run |
| Active | ✅ |
### 4.2 Test Webhook
Use Gitea's "Test Delivery" button → should return 200 with "Event ignored"
---
## 5. Caddy Reverse Proxy (Auto-HTTPS)
### 5.1 Install Caddy
```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
### 5.2 Configure Caddyfile
```bash
# Copy Caddyfile template
cp workspace/sprint_1_2/CODEBASE/deps/spec/Caddyfile.example /opt/pilot-project/Caddyfile
# Edit with your domain
sudo vim /opt/pilot-project/Caddyfile
```
### 5.3 Caddyfile Content
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
### 5.4 Run Caddy (Docker Compose - Recommended)
Add to your `docker-compose.frontend.yaml` or create separate `docker-compose.caddy.yaml`:
```yaml
# docker-compose.caddy.yaml
version: '3.8'
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /opt/pilot-project/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- pilot-network
volumes:
caddy_data:
caddy_config:
networks:
pilot-network:
external: true
```
```bash
# Start Caddy
docker compose -f docker-compose.caddy.yaml up -d
# Or run directly on host (if not using Docker)
# sudo systemctl enable --now caddy
```
### 5.5 Verify HTTPS
```bash
# Wait ~30s for Let's Encrypt cert issuance
curl -I https://app.example.com/health
# Should return 200 with strict-transport-security header
```
---
## 6. First Deployment Test
### 6.1 Trigger CI
```bash
cd <local-repo>
# Make a small change
echo "# test" >> workspace/sprint_1_2/CODEBASE/frontend/implementation/README.md
git add . && git commit -m "test: trigger CI" && git push origin main
```
### 6.2 Monitor
- **Gitea Actions tab** → Watch workflow run
- **Lightsail** → `journalctl -u gitea-webhook-listener -f`
- **ECR Public Console** → Verify image pushed
### 6.3 Verify
```bash
# Via Caddy (HTTPS)
curl https://app.example.com/health
# Should return "healthy"
# Direct to frontend (HTTP, for debugging)
curl http://<lightsail-ip>:8080/health
# Should return "healthy"
```
---
## 7. Rollback Procedure
```bash
# On Lightsail
docker images public.ecr.aws/vkist-pilot/lumina-frontend
# Tag previous SHA as latest
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> \
public.ecr.aws/vkist-pilot/lumina-frontend:latest
# Redeploy
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## 8. Cost Estimate (Monthly)
| Component | Cost |
|-----------|------|
| Lightsail 2 GB | $10 |
| ECR Public storage (1 GB) | ~$0.10 |
| ECR Public data transfer | Free (public pulls) |
| **Total** | **~$10.10/month** |
---
## 9. Troubleshooting
| Issue | Check |
|-------|-------|
| CI fails at ECR login | AWS credentials in Gitea secrets, IAM policy |
| Image not found on pull | Registry alias correct, image pushed to ECR |
| Webhook 401 | `WEBHOOK_SECRET` matches in .env and Gitea |
| Container unhealthy | `docker logs lumina-frontend`, check nginx config |
| Caddy cert not issued | Port 80/443 open on Lightsail firewall, DNS points to Lightsail IP |
| OOM on 2 GB VM | Reduce `memory: 128m` in compose, add swap |
---
## 10. File Inventory
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `nginx.conf` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | SPA routing + caching (inside container) |
| `docker-compose.frontend.yaml` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Production runtime |
| `webhook-deploy.sh` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Deploy script |
| `webhook-listener.py` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Webhook HTTP server |
| `gitea-webhook-listener.service` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Systemd unit |
| `env.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Environment template |
| `Caddyfile.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Caddy config template |
| `ci_cd_docker_registry_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Full documentation |
| `ci_cd_ecr_public_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | ECR Public specific docs |
---
*Generated for VKIST Pilot Project — MSK Ultrasound Stack*
*Compatible with: Gitea 1.21+, Docker 24+, Node 20, AWS ECR Public, Caddy 2*

View File

@@ -0,0 +1,478 @@
# CI/CD Flow: Docker Registry-Based Deployment (with Caddy)
## Overview
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to a container registry, and the Lightsail VM pulls and deploys the image via Docker Compose. **Caddy** handles automatic HTTPS termination and reverse proxying.
---
## Architecture Diagram
```mermaid
sequenceDiagram
autonumber
actor Dev as Developer
participant Git as Local Git
participant Gitea as Gitea (Lightsail VM)
participant CI as Gitea Actions Runner
participant Registry as Container Registry
participant Lightsail as Lightsail VM (Production)
participant Caddy as Caddy (HTTPS + Reverse Proxy)
Dev->>Git: git push origin main
Git->>Gitea: Push commits
Note over Gitea: Receives push event
Gitea->>CI: Dispatch workflow job
par CI Build Stage
CI->>CI: Checkout code (actions/checkout@v4)
CI->>CI: Setup Node.js 26 (actions/setup-node@v4)
CI->>CI: npm ci (install deps)
CI->>CI: npm run build (verify build)
CI->>CI: Login to registry (docker/login-action)
CI->>CI: Build Docker image (docker/build-push-action)
CI->>Registry: Push image (tagged with SHA + latest)
end
Note over Registry: Image stored: registry.example.com/lumina-frontend:sha123
par Deploy Stage
Lightsail->>Registry: Pull latest image
Lightsail->>Lightsail: docker compose pull
Lightsail->>Lightsail: docker compose up -d --force-recreate
Lightsail->>Caddy: Automatic HTTPS (no reload needed)
end
Dev->>Caddy: Access https://app.example.com
Caddy->>Lightsail: Proxy to frontend container (localhost:8080)
Lightsail->>Dev: Serves frontend
```
---
## Component Diagram (C4)
```mermaid
C4Container
title Container Diagram — Docker Registry CI/CD with Caddy
Person(dev, "Developer", "Writes code, pushes to Gitea")
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
}
System_Boundary(registry_boundary, "Container Registry") {
Container(registry, "Registry", "Docker Hub / GCR / ECR / Harbor", "Stores built images")
}
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
Container(frontend, "Frontend Container", "nginx:alpine", "Serves Lumina MSK static assets")
Container(caddy, "Caddy", "HTTPS + Reverse Proxy", "Auto-TLS via Let's Encrypt, proxies to frontend")
}
Rel(dev, gitea, "git push", "HTTPS/SSH")
Rel(gitea, runner, "Dispatches job", "Internal API")
Rel(runner, registry, "docker push", "HTTPS")
Rel(compose, registry, "docker pull", "HTTPS")
Rel(compose, frontend, "Manages container", "Docker API")
Rel(caddy, frontend, "Reverse proxy", "HTTP (localhost:8080)")
Rel(dev, caddy, "Accesses app", "HTTPS (auto-TLS)")
```
---
## Flow Description
### 1. Trigger Phase
| Event | Condition | Workflow File |
|-------|-----------|---------------|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
| Manual dispatch | Via Gitea UI | Same workflow |
### 2. CI Build Stage (Runs on Gitea Actions Runner)
| Step | Tool | Purpose |
|------|------|---------|
| Checkout | `actions/checkout@v4` | Clone repository |
| Setup Node | `actions/setup-node@v4` | Node.js 26 + cache `node_modules` |
| Install deps | `npm ci` | Clean, reproducible install |
| Verify build | `npm run build` | Runs `sync-wasm``tsc -b``vite build`; fails if TypeScript errors |
| Login registry | `docker/login-action@v3` | Authenticate to registry using secrets |
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
**Image tags pushed:**
- `registry.example.com/lumina-frontend:<git-sha>` — immutable, traceable
- `registry.example.com/lumina-frontend:latest` — rolling pointer for deploy
### 3. Registry
Supported registries (pick one):
- **Docker Hub** — public/private, generous free tier
- **GitHub Container Registry (GHCR)** — integrated with GitHub, but Gitea can push
- **Google Container Registry (GCR)** — if on GCP
- **Amazon ECR Public** — free public images, authenticated pushes
- **Harbor / self-hosted** — full control, on Lightsail or separate VM
**Required secrets in Gitea:**
| Secret Name | Value |
|-------------|-------|
| `REGISTRY_URL` | e.g., `docker.io` or `ghcr.io` |
| `REGISTRY_USER` | Registry username |
| `REGISTRY_PASS` | Registry password / token |
### 4. Deploy Stage (Runs on Lightsail VM)
Two sub-options:
#### 4a. Polling / Cron (Simplest)
```bash
# /etc/cron.hourly/deploy-frontend
#!/bin/bash
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
docker image prune -f
```
#### 4b. Webhook Listener (Event-driven)
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
```bash
# webhook-deploy.sh (triggered by webhook)
cd /opt/pilot-project
git pull # optional: ensure compose file is current
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
Gitea webhook config:
- **URL**: `http://<lightsail-ip>:3333/deploy`
- **Events**: Workflow run completed (success)
- **Secret**: Shared HMAC secret
### 5. Runtime on Lightsail (with Caddy)
#### Docker Compose
```yaml
# docker-compose.frontend.yaml
version: '3.8'
services:
lumina-frontend:
image: registry.example.com/lumina-frontend:latest
container_name: lumina-frontend
restart: unless-stopped
expose:
- "80" # Internal only, Caddy proxies to this
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- lumina-frontend
volumes:
caddy_data:
caddy_config:
networks:
default:
name: pilot-network
external: true
```
#### Caddyfile (Auto-HTTPS)
```text
# Caddyfile
# Replace with your actual domain
app.example.com {
# Reverse proxy to frontend container
reverse_proxy lumina-frontend:80
# Optional: Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Cache static assets
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
**Key benefits of Caddy:**
- **Automatic HTTPS** — No manual cert management, Let's Encrypt handled automatically
- **HTTP/2 & HTTP/3** — Enabled by default
- **Zero-downtime reloads** — Config changes don't drop connections
- **No external ACME client needed** — Built-in
---
## Required Artifacts to Create
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy config with domain |
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | Registry credentials |
---
## Secrets Management
| Secret | Where | Scope |
|--------|-------|-------|
| `REGISTRY_URL` | Gitea repo secrets | CI build step |
| `REGISTRY_USER` | Gitea repo secrets | CI build step |
| `REGISTRY_PASS` | Gitea repo secrets | CI build step |
| `WEBHOOK_SECRET` | Gitea webhook config + Lightsail env | Deploy trigger auth |
---
## Failure Scenarios & Mitigations
| Scenario | Detection | Mitigation |
|----------|-----------|------------|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
| Registry push fails | CI step fails | Check credentials, quota |
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
| Caddy cert failure | Caddy logs (`docker logs caddy`) | Check domain DNS, port 80/443 reachable |
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
---
## Rollback
```bash
# On Lightsail: list available tags
docker images registry.example.com/lumina-frontend
# Rollback to specific SHA
docker tag registry.example.com/lumina-frontend:<old-sha> registry.example.com/lumina-frontend:latest
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## Cost Estimate (Monthly)
| Component | Estimate |
|-----------|----------|
| Lightsail 2 GB instance | $1012 |
| Registry (Docker Hub free / GHCR) | $05 |
| Data transfer | < $1 |
| **Total** | **~$1018 / month** |
---
## Next Steps
1. Choose a container registry and create repo/namespace
2. Add registry secrets to Gitea
3. Create `.gitea/workflows/frontend.yaml` (see appendix)
4. Create `Dockerfile` in frontend implementation dir
5. Deploy `docker-compose.frontend.yaml` + `Caddyfile` to Lightsail
6. Point domain DNS to Lightsail public IP
7. Test end-to-end: push to `main` → verify CI → verify deploy → verify HTTPS
---
## Appendix: Minimal Workflow File
```yaml
# .gitea/workflows/frontend.yaml
name: Frontend CI/CD
on:
push:
branches: [main]
paths:
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Build frontend (verify)
run: npm run build
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/lumina-frontend
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
---
## Appendix: Minimal Dockerfile
```dockerfile
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
# Custom nginx config for SPA + caching
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
---
## Appendix: nginx.conf (for Frontend Container)
```nginx
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets aggressively
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
}
```
---
## Appendix: Caddyfile (for Lightsail)
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
---
*Document version: 2.0 (Caddy integration)*
*Last updated: 2026-07-17*
*Owner: Platform Engineering / VKIST Team*

View File

@@ -0,0 +1,756 @@
# CI/CD Flow: Docker Registry-Based Deployment (Amazon ECR Public)
## Overview
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to **Amazon ECR Public**, and the Lightsail VM pulls and deploys the image via Docker Compose with **Caddy** as the reverse proxy (auto-HTTPS via Let's Encrypt).
---
## Architecture Diagram
```mermaid
sequenceDiagram
autonumber
actor Dev as Developer
participant Git as Local Git
participant Gitea as Gitea (Lightsail VM)
participant CI as Gitea Actions Runner
participant ECR as Amazon ECR Public
participant Lightsail as Lightsail VM (Production)
participant Caddy as Caddy (Reverse Proxy + TLS)
Dev->>Git: git push origin main
Git->>Gitea: Push commits
Note over Gitea: Receives push event
Gitea->>CI: Dispatch workflow job
par CI Build Stage
CI->>CI: Checkout code (actions/checkout@v4)
CI->>CI: Configure AWS credentials (aws-actions/configure-aws-credentials)
CI->>CI: Login to ECR Public (aws-actions/amazon-ecr-login)
CI->>CI: Setup Node.js 20 (actions/setup-node@v4)
CI->>CI: npm ci (install deps)
CI->>CI: npm run build (verify build)
CI->>CI: Build Docker image (docker/build-push-action)
CI->>ECR: Push image (tagged with SHA + latest)
end
Note over ECR: Image stored: public.ecr.aws/<alias>/lumina-frontend:sha123
par Deploy Stage
Lightsail->>ECR: Pull latest image (public, no auth needed for pull)
Lightsail->>Lightsail: docker compose pull
Lightsail->>Lightsail: docker compose up -d --force-recreate
Lightsail->>Caddy: Reload config (if needed)
end
Dev->>Lightsail: Access https://app.example.com
Lightsail->>Dev: Serves frontend (auto-HTTPS)
```
---
## Component Diagram (C4)
```mermaid
C4Container
title Container Diagram — ECR Public CI/CD with Caddy
Person(dev, "Developer", "Writes code, pushes to Gitea")
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
}
System_Boundary(aws_boundary, "AWS Cloud") {
Container(ecr, "Amazon ECR Public", "public.ecr.aws", "Stores public container images")
}
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
Container(caddy, "Caddy", "Reverse Proxy + TLS", "Auto-HTTPS via Let's Encrypt, serves Lumina MSK frontend")
}
Rel(dev, gitea, "git push", "HTTPS/SSH")
Rel(gitea, runner, "Dispatches job", "Internal API")
Rel(runner, ecr, "docker push (authenticated)", "HTTPS + AWS SigV4")
Rel(compose, ecr, "docker pull (public)", "HTTPS")
Rel(compose, caddy, "Proxies to frontend", "Docker network")
Rel(dev, caddy, "Accesses app", "HTTPS (auto)")
```
---
## Flow Description
### 1. Trigger Phase
| Event | Condition | Workflow File |
|-------|-----------|---------------|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
| Manual dispatch | Via Gitea UI | Same workflow |
### 2. CI Build Stage (Runs on Gitea Actions Runner)
| Step | Tool | Purpose |
|------|------|---------|
| Checkout | `actions/checkout@v4` | Clone repository |
| Configure AWS | `aws-actions/configure-aws-credentials@v4` | Set AWS credentials for ECR access |
| Login to ECR Public | `aws-actions/amazon-ecr-login@v2` | Authenticate Docker to ECR Public |
| Setup Node | `actions/setup-node@v4` | Node.js 20 + cache `node_modules` |
| Install deps | `npm ci` | Clean, reproducible install |
| Verify build | `npm run build` | Runs `sync-wasm``tsc -b``vite build`; fails if TypeScript errors |
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
**Image tags pushed:**
- `public.ecr.aws/<registry-alias>/lumina-frontend:<git-sha>` — immutable, traceable
- `public.ecr.aws/<registry-alias>/lumina-frontend:latest` — rolling pointer for deploy
### 3. Amazon ECR Public
**Repository URI format:**
```
public.ecr.aws/<registry-alias>/lumina-frontend
```
**Key properties:**
- **Public pulls** — No authentication required for `docker pull`
- **Authenticated pushes** — Requires AWS credentials with `ecr-public:PutImage`, `ecr-public:InitiateLayerUpload`, `ecr-public:UploadLayerPart`, `ecr-public:CompleteLayerUpload`
- **Registry alias** — Created once per AWS account (e.g., `vkist-pilot`)
- **Region** — `us-east-1` (ECR Public is only in us-east-1)
**Required IAM permissions for CI user/role:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"ecr-public:InitiateLayerUpload",
"ecr-public:UploadLayerPart",
"ecr-public:CompleteLayerUpload",
"ecr-public:PutImage"
],
"Resource": "*"
}
]
}
```
**Required secrets in Gitea:**
| Secret Name | Value |
|-------------|-------|
| `AWS_ACCESS_KEY_ID` | IAM user access key with ECR Public permissions |
| `AWS_SECRET_ACCESS_KEY` | Corresponding secret key |
| `AWS_ECR_REGION` | `us-east-1` (ECR Public region) |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your ECR Public registry alias (e.g., `vkist-pilot`) |
### 4. Deploy Stage (Runs on Lightsail VM)
Since ECR Public images are publicly pullable, **no AWS credentials needed on Lightsail** for deployment.
Two sub-options:
#### 4a. Polling / Cron (Simplest)
```bash
#!/bin/bash
# /etc/cron.hourly/deploy-frontend
cd /opt/pilot-project
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
docker image prune -f
```
#### 4b. Webhook Listener (Event-driven)
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
```bash
#!/bin/bash
# /opt/pilot-project/webhook-deploy.sh
set -e
cd /opt/pilot-project
git pull # optional: ensure compose file is current
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
```python
#!/usr/bin/env python3
"""
Gitea Webhook Listener - secrets from AWS SSM Parameter Store
"""
import os
import hmac
import hashlib
import json
import subprocess
import logging
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
try:
import boto3
except ImportError:
print("ERROR: boto3 required. Install: pip install boto3")
exit(1)
# Configuration
SSM_PREFIX = "/lumina"
AWS_ECR_REGION = os.environ.get("AWS_ECR_REGION", "us-east-1")
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 3333
DEPLOY_SCRIPT = Path(__file__).parent / "webhook-deploy.sh"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
def load_secrets_from_ssm() -> dict:
"""Fetch all parameters under /lumina prefix from SSM Parameter Store."""
client = boto3.client("ssm", region_name=AWS_ECR_REGION)
response = client.get_parameters_by_path(
Path=SSM_PREFIX,
WithDecryption=True,
Recursive=True
)
secrets = {}
for param in response.get("Parameters", []):
key = param["Name"].split("/")[-1].upper()
secrets[key] = param["Value"]
return secrets
SECRETS = load_secrets_from_ssm()
WEBHOOK_SECRET = SECRETS.get("WEBHOOK_SECRET", "")
ECR_REGISTRY_ALIAS = SECRETS.get("ECR_REGISTRY_ALIAS", "")
if not WEBHOOK_SECRET:
logger.error("WEBHOOK_SECRET not found in SSM at %s/webhook-secret", SSM_PREFIX)
exit(1)
def verify_signature(payload: bytes, signature_header: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/deploy":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
payload = self.rfile.read(content_length)
signature = self.headers.get("X-Gitea-Signature", "")
if not verify_signature(payload, signature):
logger.warning("Invalid signature from %s", self.client_address[0])
self.send_response(401)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
try:
event = json.loads(payload)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
return
# Only deploy on successful frontend workflow
if event.get("workflow_run", {}).get("conclusion") != "success":
logger.info("Workflow not successful, ignoring")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Ignored")
return
logger.info("Triggering deployment")
try:
result = subprocess.run(
[str(DEPLOY_SCRIPT)],
capture_output=True,
text=True,
timeout=300,
env={**os.environ, "ECR_PUBLIC_REGISTRY_ALIAS": ECR_REGISTRY_ALIAS}
)
if result.returncode == 0:
self.send_response(200)
self.end_headers()
self.wfile.write(b"Deployed")
else:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Failed: {result.stderr}".encode())
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(f"Error: {e}".encode())
def log_message(self, format, *args):
logger.info("%s - %s", self.client_address[0], format % args)
def main():
if not DEPLOY_SCRIPT.exists():
logger.error("Deploy script not found: %s", DEPLOY_SCRIPT)
exit(1)
os.chmod(DEPLOY_SCRIPT, 0o755)
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler)
logger.info("Listener on %s:%d (registry: %s)", LISTEN_HOST, LISTEN_PORT, ECR_REGISTRY_ALIAS)
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
if __name__ == "__main__":
main()
```
**systemd unit** (`/etc/systemd/system/gitea-webhook-listener.service`):
```ini
[Unit]
Description=Gitea Webhook Listener (Lumina Frontend)
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/pilot-project
ExecStart=/usr/bin/python3 /opt/pilot-project/webhook-listener.py
Restart=always
RestartSec=10
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/pilot-project
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
**Gitea webhook config:**
- **URL**: `http://<lightsail-public-ip>:3333/deploy` (or via Caddy proxy)
- **Events**: Workflow Run → Completed
- **Secret**: Same `WEBHOOK_SECRET` value (stored in SSM)
- **Content-Type**: `application/json`
### 5. Runtime on Lightsail with Caddy
**Docker Compose** (`docker-compose.frontend.yaml`):
```yaml
version: '3.8'
services:
lumina-frontend:
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest
container_name: lumina-frontend
restart: unless-stopped
expose:
- "80"
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- lumina-frontend
networks:
default:
name: pilot-network
external: true
volumes:
caddy_data:
caddy_config:
```
**Caddyfile** (`/opt/pilot-project/Caddyfile`):
```text
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
**Environment file** (`/opt/pilot-project/.env`):
```bash
ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
```
---
## Required Artifacts to Create
| File | Location | Purpose |
|------|----------|---------|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy reverse proxy config |
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
| `webhook-listener.py` (optional) | `/opt/pilot-project/` on Lightsail | Webhook server (secrets from SSM) |
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
| `.env` | `/opt/pilot-project/` on Lightsail | Registry alias variable |
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | AWS credentials + alias |
| SSM Parameters | AWS Console → Systems Manager → Parameter Store | `WEBHOOK_SECRET`, `ECR_REGISTRY_ALIAS` |
---
## Secrets Management
| Secret | Where | Scope |
|--------|-------|-------|
| `AWS_ACCESS_KEY_ID` | Gitea repo secrets | CI: ECR authentication |
| `AWS_SECRET_ACCESS_KEY` | Gitea repo secrets | CI: ECR authentication |
| `AWS_ECR_REGION` | Gitea repo secrets | CI: `us-east-1` |
| `ECR_PUBLIC_REGISTRY_ALIAS` | Gitea repo secrets + Lightsail `.env` | CI tag + Compose image ref |
| `WEBHOOK_SECRET` | **AWS SSM SecureString** (`/lumina/webhook-secret`) | Deploy trigger auth |
| `ECR_REGISTRY_ALIAS` | **AWS SSM String** (`/lumina/ecr-registry-alias`) | Deploy script env var |
**Lightsail IAM Instance Profile** must have:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
"Resource": "arn:aws:ssm:us-east-1:<account-id>:parameter/lumina/*"
}
]
}
```
---
## ECR Public Setup Steps
### 1. Create ECR Public Repository
```bash
aws ecr-public create-repository \
--repository-name lumina-frontend \
--region us-east-1
```
Note the `registryAlias` from output (e.g., `vkist-pilot`).
### 2. Create IAM User for CI
```bash
aws iam create-user --user-name gitea-ci-ecr-public
aws iam attach-user-policy \
--user-name gitea-ci-ecr-public \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPublicFullAccess
# Or use custom least-privilege policy above
aws iam create-access-key --user-name gitea-ci-ecr-public
```
Save the Access Key ID and Secret Access Key.
### 3. Add Secrets to Gitea
Gitea UI → Repository → Settings → Secrets → Add:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_ECR_REGION` = `us-east-1`
- `ECR_PUBLIC_REGISTRY_ALIAS` = your alias (e.g., `vkist-pilot`)
### 4. Store Secrets in SSM Parameter Store
```bash
# Webhook secret (generate once)
aws ssm put-parameter \
--name "/lumina/webhook-secret" \
--value "$(openssl rand -hex 32)" \
--type "SecureString" \
--region us-east-1
# Registry alias (not sensitive, but convenient)
aws ssm put-parameter \
--name "/lumina/ecr-registry-alias" \
--value "vkist-pilot" \
--type "String" \
--region us-east-1
```
### 5. Attach IAM Instance Profile to Lightsail
AWS Console → Lightsail → Your Instance → Networking → Attach IAM instance profile → Select profile with SSM read access.
---
## Failure Scenarios & Mitigations
| Scenario | Detection | Mitigation |
|----------|-----------|------------|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
| ECR login fails | CI step fails | Check AWS credentials, permissions |
| Push fails (throttling) | CI step fails | Retry, check ECR Public rate limits |
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
| Caddy TLS fails | Caddy logs | Check DNS, port 80/443 reachable |
---
## Rollback
```bash
# On Lightsail: list available tags (requires AWS CLI for ECR Public)
aws ecr-public describe-images \
--repository-name lumina-frontend \
--region us-east-1 \
--query 'imageDetails[*].imageTags' \
--output text
# Rollback to specific SHA
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> public.ecr.aws/vkist-pilot/lumina-frontend:latest
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
```
---
## Cost Estimate (Monthly)
| Component | Estimate |
|-----------|----------|
| Lightsail 2 GB instance | $1012 |
| ECR Public storage (first 50 GB) | Free |
| ECR Public data transfer (first 5 TB/mo to internet) | Free |
| Data transfer (Lightsail ↔ ECR Public) | < $1 |
| **Total** | **~$1013 / month** |
---
## Next Steps
1. Create ECR Public repository `lumina-frontend` in `us-east-1`
2. Create IAM user `gitea-ci-ecr-public` with ECR Public permissions
3. Add AWS credentials + registry alias to Gitea secrets
4. Store `WEBHOOK_SECRET` and `ECR_REGISTRY_ALIAS` in AWS SSM Parameter Store
5. Create `.gitea/workflows/frontend.yaml` (see appendix)
6. Create `Dockerfile` in frontend implementation dir
7. Deploy `docker-compose.frontend.yaml`, `Caddyfile`, `.env` to Lightsail
8. Attach IAM instance profile with SSM read access to Lightsail
9. Test end-to-end: push to `main` → verify CI → verify deploy + HTTPS
---
## Appendix: Workflow File for ECR Public
```yaml
# .gitea/workflows/frontend.yaml
name: Frontend CI/CD (ECR Public)
on:
push:
branches: [main]
paths:
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
workflow_dispatch: {}
permissions:
contents: read
id-token: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_ECR_REGION }}
- name: Login to Amazon ECR Public
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Build frontend (verify)
run: npm run build
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: public.ecr.aws/${{ secrets.ECR_PUBLIC_REGISTRY_ALIAS }}/lumina-frontend
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
---
## Appendix: Minimal Dockerfile
```dockerfile
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM nginx:alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
---
## Appendix: nginx.conf for SPA (inside container)
```nginx
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets aggressively
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Main location - SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
}
```
---
## Appendix: Caddyfile (Lightsail)
```text
# /opt/pilot-project/Caddyfile
# Replace app.example.com with your actual domain
app.example.com {
reverse_proxy lumina-frontend:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
@static {
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
}
header @static Cache-Control "public, max-age=31536000, immutable"
}
```
---
*Document version: 2.0 (Caddy + SSM integration)*
*Last updated: 2026-07-17*
*Registry: Amazon ECR Public (us-east-1)*
*Reverse Proxy: Caddy (auto-HTTPS via Let's Encrypt)*
*Secrets: AWS SSM Parameter Store*
*Owner: Platform Engineering / VKIST Team*