test_workflow
Some checks failed
Some checks failed
This commit is contained in:
@@ -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*
|
||||
@@ -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 | $10–12 |
|
||||
| Registry (Docker Hub free / GHCR) | $0–5 |
|
||||
| Data transfer | < $1 |
|
||||
| **Total** | **~$10–18 / 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*
|
||||
@@ -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 | $10–12 |
|
||||
| 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** | **~$10–13 / 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*
|
||||
Reference in New Issue
Block a user