Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 41 additions & 33 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,69 +10,77 @@ concurrency:

permissions:
contents: read
packages: write
id-token: write # OIDC role assumption for AWS

jobs:
build-push:
name: Build & Push (${{ matrix.app }})
runs-on: ubuntu-latest
environment: build # OIDC sub claim must match the role's environment condition
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- app: api
context: .
dockerfile: apps/api/Dockerfile
stack: prod
- app: web
context: .
dockerfile: apps/web/Dockerfile
stack: prod
- app: api-go
dockerfile: apps/api-go/Dockerfile
stack: lab
- app: api-axum
dockerfile: apps/api-axum/Dockerfile
stack: lab
- app: api-elysia
dockerfile: apps/api-elysia/Dockerfile
stack: lab
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
with:
driver: docker
- uses: docker/login-action@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/webdev-deploy-build
aws-region: ap-southeast-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: |
ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}/${{ matrix.app }}:latest
${{ steps.ecr.outputs.registry }}/webdev-${{ matrix.stack }}-${{ matrix.app }}:${{ github.ref_name }}
${{ steps.ecr.outputs.registry }}/webdev-${{ matrix.stack }}-${{ matrix.app }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
name: Deploy (VPS)
name: Deploy (AWS)
needs: build-push
runs-on: ubuntu-latest
environment: production # manual approval gate lives here
steps:
- name: Deploy production
uses: appleboy/ssh-action@v1
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v4
with:
host: ${{ secrets.SSH_HOST }}
port: ${{ secrets.SSH_PORT }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
timeout: 30m
script: |
set -e
COMPOSE_DIR="/opt/webdevstudios-prod"
mkdir -p $COMPOSE_DIR
curl -fsSL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.raw" \
-o $COMPOSE_DIR/docker-compose.prod.yml \
"https://api.github.com/repos/${{ github.repository }}/contents/docker-compose.prod.yml?ref=main"
curl -fsSL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.raw" \
-o $COMPOSE_DIR/deploy.sh \
"https://api.github.com/repos/${{ github.repository }}/contents/scripts/deploy.sh?ref=main"
chmod +x $COMPOSE_DIR/deploy.sh
cd $COMPOSE_DIR
./deploy.sh production
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/webdev-deploy-production
aws-region: ap-southeast-1
- uses: oven-sh/setup-bun@v2
- name: Build web-vue static bundle for BucketDeployment
run: |
bun install --frozen-lockfile
bun run --cwd apps/web-vue build
- name: Deploy CDK stacks
working-directory: infra
env:
ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }}
CDK_DEFAULT_ACCOUNT: ${{ vars.AWS_ACCOUNT_ID }}
CDK_DEFAULT_REGION: ap-southeast-1
run: |
bun install --frozen-lockfile
bunx --bun cdk deploy --all --require-approval never -c tag=${{ github.ref_name }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ apps/api-go/.env
apps/api-go/server
apps/api-go/api-go
*.exe
infra/cdk.out
9 changes: 5 additions & 4 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Context map

This repo has four contexts, one per app. Each context has its own `CONTEXT.md` and `apps/<app>/docs/adr/`.
This repo has six contexts, one per app. Each context has its own `CONTEXT.md` and `apps/<app>/docs/adr/`.

- **api** — NestJS backend API. See `apps/api/CONTEXT.md` (created lazily by `/domain-modeling`).
- **api-go** — Go port of the API. See `apps/api-go/CONTEXT.md`.
- **api-axum** — Rust Axum port of the API. See `apps/api-axum/CONTEXT.md`.
- **api-go** — Go Mirror of the API. See `apps/api-go/CONTEXT.md`.
- **api-axum** — Rust Axum Mirror of the API. See `apps/api-axum/CONTEXT.md`.
- **api-elysia** — Elysia/Bun Mirror of the API. See `apps/api-elysia/CONTEXT.md`.
- **web** — frontend web app. See `apps/web/CONTEXT.md` (created lazily by `/domain-modeling`).
- **web-vue** — Vue mirror of web. See `apps/web-vue/CONTEXT.md`.
- **web-vue** — Vue Mirror of web. See `apps/web-vue/CONTEXT.md`.

System-wide decisions live in `docs/adr/`.
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,19 @@ _Avoid_: feedback, rating
**SecurityLog**:
An append-only audit record of security-relevant actions (auth successes, authorization failures, rate-limit hits).
_Avoid_: log entry, audit entry

**Service**:
One deployable application — each backend and each frontend is its own Service, deployed and scaled on its own.
_Avoid_: app, microservice, container

**Mirror**:
A re-implementation of the api or web Service in another language or framework, built for learning; it shares the schema and the business rules (see ShippingFee) but serves no real traffic.
_Avoid_: port, clone, fork

**Prod stack**:
The Services that serve real traffic — api and web — with high availability, blue/green deploys, and paid-tier isolation.
_Avoid_: live, main

**Lab stack**:
The Mirrors plus web-vue, deployed for learning and comparison: single-AZ, no WAF, off on a nightly schedule.
_Avoid_: staging, test
19 changes: 19 additions & 0 deletions apps/api-axum/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
# Build context = repo root: docker build -f apps/api-axum/Dockerfile .

FROM rust:1-slim-bookworm AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY apps/api-axum/Cargo.toml apps/api-axum/Cargo.lock ./
COPY apps/api-axum/src ./src
COPY apps/api-axum/templates ./templates
RUN cargo build --release --locked && strip target/release/api-axum

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/target/release/api-axum /usr/local/bin/api-axum
COPY --from=builder /src/templates /templates
ENV PORT=4000
EXPOSE 4000
USER nobody
ENTRYPOINT ["api-axum"]
23 changes: 23 additions & 0 deletions apps/api-elysia/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
# Build context = repo root: docker build -f apps/api-elysia/Dockerfile .
# ponytail: single-stage install + run src with bun (bun needs no build step).
# api's layered cache stages only matter for prod deploys; this Mirror boots from src.

FROM oven/bun:1 AS base
WORKDIR /app

COPY package.json bun.lock ./
COPY apps/api-elysia/package.json ./apps/api-elysia/
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/

RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --ignore-scripts

COPY . .

ENV NODE_ENV=production
ENV PORT=4000

EXPOSE 4000
CMD ["bun", "apps/api-elysia/src/index.ts"]
18 changes: 18 additions & 0 deletions apps/api-go/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1
# Build context = repo root: docker build -f apps/api-go/Dockerfile .

FROM golang:1.25-alpine AS builder
# go.mod pins 1.27; GOTOOLCHAIN=auto downloads it inside the 1.25 image.
ENV CGO_ENABLED=0 GOTOOLCHAIN=auto
WORKDIR /src
COPY apps/api-go/go.mod apps/api-go/go.sum ./
RUN go mod download
COPY apps/api-go/ .
RUN go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
ENV PORT=4000
EXPOSE 4000
USER nonroot
ENTRYPOINT ["/server"]
4 changes: 3 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ SWAGGER_PASSWORD=xiropassword
CSRF_SECRET="csrf-secret"

# Mail
MAIL_USER=""
MAIL_HOST="smtp.resend.com"
MAIL_PORT="465"
MAIL_USER="resend"
MAIL_PASS=""

# Payment
Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/mail/mail.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ import { MailService } from './mail.service';
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: {
host: 'smtp.ethereal.email',
port: 587,
secure: false,
service: 'gmail',
host: config.get<string>('MAIL_HOST', 'smtp.ethereal.email'),
port: Number(config.get<string>('MAIL_PORT') ?? '587'),
secure: config.get<string>('MAIL_HOST') === 'smtp.resend.com',
auth: {
user: config.getOrThrow<string>('MAIL_USER'),
pass: config.getOrThrow<string>('MAIL_PASS'),
Expand Down
25 changes: 25 additions & 0 deletions apps/web-vue/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
# Build context = repo root: docker build -f apps/web-vue/Dockerfile .
# ponytail: cloud serves web-vue static from S3+CloudFront; this image exists for
# local/LAN parity (xiroserver-lan) where S3 is not in the loop.

FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
COPY apps/web-vue/package.json ./apps/web-vue/
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile --ignore-scripts
COPY . .
# Baked at build time: web-vue reads VITE_API_URL into the bundle (src/lib/constants.ts).
ARG VITE_API_URL=http://localhost:4000
ARG VITE_SITE_URL=http://localhost:3001
ARG VITE_CDN_URL=http://localhost:3001
ENV VITE_API_URL=$VITE_API_URL VITE_SITE_URL=$VITE_SITE_URL VITE_CDN_URL=$VITE_CDN_URL
RUN bun run --cwd apps/web-vue build

FROM nginx:1-alpine
COPY --from=builder /app/apps/web-vue/dist /usr/share/nginx/html
COPY apps/web-vue/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
11 changes: 11 additions & 0 deletions apps/web-vue/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Loading
Loading