feat: add CI/CD pipeline, production Dockerfile, and deployment architecture
- Add production Dockerfile with bun build --compile, multi-stage Alpine build - Add .dockerignore - Swap bcrypt -> bcryptjs (pure JS, no native addons) - Add programmatic migrations on startup via drizzle migrator - Add /v1/version endpoint with APP_VERSION baked in at build time - Add .gitea/workflows/ci.yml (lint + test with postgres/valkey services) - Add .gitea/workflows/build.yml (version bump, build, push to registry) - Update CLAUDE.md and docs/architecture.md to remove multi-tenancy - Add docs/deployment.md covering DOKS + ArgoCD architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
docs
|
||||||
|
planning
|
||||||
|
deploy
|
||||||
|
infra
|
||||||
|
packages/admin
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
*.md
|
||||||
77
.gitea/workflows/build.yml
Normal file
77
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Build & Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.BOT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine version bump
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||||
|
if echo "$COMMIT_MSG" | grep -qiE "^breaking(\(.+\))?:|^.+!:"; then
|
||||||
|
echo "type=major" >> $GITHUB_OUTPUT
|
||||||
|
elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(.+\))?:"; then
|
||||||
|
echo "type=minor" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "type=patch" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bump version in package.json
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
cd packages/backend
|
||||||
|
npm version ${{ steps.bump.outputs.type }} --no-git-tag-version
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Commit version bump
|
||||||
|
run: |
|
||||||
|
git config user.name "lunarfront-bot"
|
||||||
|
git config user.email "bot@lunarfront.tech"
|
||||||
|
git remote set-url origin https://lunarfront-bot:${{ secrets.BOT_TOKEN }}@git.lunarfront.tech/ryan/lunarfront-app.git
|
||||||
|
git add packages/backend/package.json
|
||||||
|
git commit -m "chore: bump version to v${{ steps.version.outputs.version }}"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y ca-certificates curl
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y docker-ce-cli
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.lunarfront.tech -u ryan --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
--build-arg APP_VERSION=$VERSION \
|
||||||
|
-t registry.lunarfront.tech/ryan/lunarfront-app:$VERSION \
|
||||||
|
-t registry.lunarfront.tech/ryan/lunarfront-app:$SHA \
|
||||||
|
-t registry.lunarfront.tech/ryan/lunarfront-app:latest \
|
||||||
|
.
|
||||||
|
docker push registry.lunarfront.tech/ryan/lunarfront-app:$VERSION
|
||||||
|
docker push registry.lunarfront.tech/ryan/lunarfront-app:$SHA
|
||||||
|
docker push registry.lunarfront.tech/ryan/lunarfront-app:latest
|
||||||
|
|
||||||
|
- name: Logout
|
||||||
|
if: always()
|
||||||
|
run: docker logout registry.lunarfront.tech
|
||||||
61
.gitea/workflows/ci.yml
Normal file
61
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: registry.lunarfront.tech/ryan/ci-runner:latest
|
||||||
|
credentials:
|
||||||
|
username: ryan
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: lunarfront
|
||||||
|
POSTGRES_PASSWORD: lunarfront
|
||||||
|
POSTGRES_DB: lunarfront_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
options: >-
|
||||||
|
--health-cmd "valkey-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://lunarfront:lunarfront@postgres:5432/lunarfront_test
|
||||||
|
REDIS_URL: redis://valkey:6379
|
||||||
|
JWT_SECRET: ci-secret
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
working-directory: packages/backend
|
||||||
|
run: bunx drizzle-kit migrate
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
@@ -24,8 +24,7 @@
|
|||||||
## Database
|
## Database
|
||||||
- Dev: `lunarfront` on localhost:5432
|
- Dev: `lunarfront` on localhost:5432
|
||||||
- Test: `lunarfront_test` on localhost:5432
|
- Test: `lunarfront_test` on localhost:5432
|
||||||
- Multi-tenant: `company_id` (uuid FK) on all domain tables for tenant isolation
|
- Each deployed instance has its own isolated database — no multi-tenancy, no `company_id`
|
||||||
- `location_id` (uuid FK) on tables that need per-location scoping (inventory, transactions, drawer)
|
|
||||||
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
|
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
|
||||||
|
|
||||||
## Key Entity Names
|
## Key Entity Names
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM oven/bun:1.3.11-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/backend/package.json packages/backend/
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.11-alpine AS build
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
|
COPY --from=deps /app/packages/backend/node_modules ./packages/backend/node_modules
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY packages/backend ./packages/backend
|
||||||
|
COPY package.json ./
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
WORKDIR /app/packages/backend
|
||||||
|
RUN bun build src/main.ts --compile --outfile /app/server \
|
||||||
|
--define "process.env.APP_VERSION='${APP_VERSION}'"
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/server ./server
|
||||||
|
COPY --from=build /app/packages/backend/src/db/migrations ./migrations
|
||||||
|
ENV MIGRATIONS_DIR=/app/migrations
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8000/v1/health || exit 1
|
||||||
|
USER app
|
||||||
|
CMD ["./server"]
|
||||||
12
bun.lock
12
bun.lock
@@ -66,7 +66,8 @@
|
|||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@lunarfront/shared": "workspace:*",
|
"@lunarfront/shared": "workspace:*",
|
||||||
"bcrypt": "^6",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.38",
|
"drizzle-orm": "^0.38",
|
||||||
"fastify": "^5",
|
"fastify": "^5",
|
||||||
"fastify-plugin": "^5",
|
"fastify-plugin": "^5",
|
||||||
@@ -75,7 +76,6 @@
|
|||||||
"zod": "^4",
|
"zod": "^4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"drizzle-kit": "^0.30",
|
"drizzle-kit": "^0.30",
|
||||||
"pino-pretty": "^13",
|
"pino-pretty": "^13",
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="],
|
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||||
|
|
||||||
"@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="],
|
"@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="],
|
||||||
|
|
||||||
@@ -614,7 +614,7 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
|
||||||
|
|
||||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
@@ -928,10 +928,6 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
|
|
||||||
|
|
||||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|||||||
@@ -56,11 +56,10 @@ src/
|
|||||||
### Request Flow
|
### Request Flow
|
||||||
|
|
||||||
1. Fastify receives request
|
1. Fastify receives request
|
||||||
2. `onRequest` hook sets `companyId` from header
|
2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
|
||||||
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
|
3. `requirePermission` preHandler checks user has required permission slug
|
||||||
4. `requirePermission` preHandler checks user has required permission slug
|
4. Route handler validates input with Zod, calls service, returns response
|
||||||
5. Route handler validates input with Zod, calls service, returns response
|
5. Error handler catches typed errors and maps to HTTP status codes
|
||||||
6. Error handler catches typed errors and maps to HTTP status codes
|
|
||||||
|
|
||||||
### Permission Inheritance
|
### Permission Inheritance
|
||||||
|
|
||||||
@@ -109,9 +108,9 @@ src/
|
|||||||
| Theme | Zustand store, persisted to localStorage |
|
| Theme | Zustand store, persisted to localStorage |
|
||||||
| Component state | React `useState` |
|
| Component state | React `useState` |
|
||||||
|
|
||||||
## Multi-Tenancy
|
## Deployment Model
|
||||||
|
|
||||||
Every domain table has a `company_id` column. All queries filter by the authenticated user's company. Location-scoped tables (inventory, transactions) additionally filter by `location_id`.
|
Each customer runs as a fully isolated deployment — their own Kubernetes namespace on DOKS, their own database on the shared managed Postgres instance. There is no multi-tenancy in the application layer. No `company_id`, no row-level isolation. One instance = one customer.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
|
|||||||
193
docs/deployment.md
Normal file
193
docs/deployment.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Infrastructure & Deployment Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
LunarFront runs on DigitalOcean. Each customer is a fully isolated deployment — their own Kubernetes namespace, their own database. There is no multi-tenancy in the application layer.
|
||||||
|
|
||||||
|
**Stack:** DOKS (Kubernetes) · ArgoCD · Helm · Gitea · Terraform · Ansible
|
||||||
|
|
||||||
|
The guiding principle is simplicity — Docker Compose on Droplets for Gitea, Kubernetes for customer app instances. No surprise bills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monthly Cost
|
||||||
|
|
||||||
|
| Resource | Spec | Cost |
|
||||||
|
|---|---|---|
|
||||||
|
| DOKS cluster | Control plane | $12/mo |
|
||||||
|
| DOKS nodes (2x s-2vcpu-4gb) | Runs all customer apps | ~$48/mo |
|
||||||
|
| Managed Postgres (shared) | All customer databases | $15-25/mo |
|
||||||
|
| Managed Redis (shared) | All customer queues/cache | $15/mo |
|
||||||
|
| Gitea Droplet | 1 vCPU, 1GB RAM | $6/mo |
|
||||||
|
| Gitea Postgres | Dedicated managed Postgres | $15/mo |
|
||||||
|
| Spaces | Registry, backups, files | ~$21/mo |
|
||||||
|
| **Fixed total** | | **~$132-142/mo** |
|
||||||
|
| **Per customer** | New database in shared Postgres | **~$0 marginal** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
| Environment | Hostname | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Production (per customer) | `{customer}.app.lunarfront.tech` | Live customer instance on DOKS |
|
||||||
|
| Gitea | `git.lunarfront.tech` | Source control, CI/CD, container registry |
|
||||||
|
| Dev | Local / feature namespace on DOKS | Testing and staging |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
Managed through Cloudflare. Records defined in Terraform.
|
||||||
|
|
||||||
|
| Record | Type | Points To |
|
||||||
|
|---|---|---|
|
||||||
|
| `git.lunarfront.tech` | A | Gitea Droplet |
|
||||||
|
| `git-ssh.lunarfront.tech` | A | Gitea Droplet (SSH port 2222) |
|
||||||
|
| `registry.lunarfront.tech` | A | Gitea Droplet (container registry) |
|
||||||
|
| `{customer}.app.lunarfront.tech` | CNAME | DOKS load balancer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure (Terraform)
|
||||||
|
|
||||||
|
Terraform manages all DigitalOcean and Cloudflare resources. State stored in Spaces.
|
||||||
|
|
||||||
|
```
|
||||||
|
/terraform
|
||||||
|
main.tf providers, backend (Spaces state)
|
||||||
|
variables.tf
|
||||||
|
cluster.tf DOKS cluster + node pools
|
||||||
|
databases.tf shared Postgres, shared Redis
|
||||||
|
gitea.tf Gitea Droplet
|
||||||
|
spaces.tf files, backups, tf-state buckets
|
||||||
|
dns.tf Cloudflare DNS records
|
||||||
|
outputs.tf cluster endpoint, DB URLs
|
||||||
|
terraform.tfvars secrets (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Backend
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
terraform {
|
||||||
|
backend "s3" {
|
||||||
|
endpoint = "https://nyc3.digitaloceanspaces.com"
|
||||||
|
bucket = "lunarfront-terraform-state"
|
||||||
|
key = "terraform.tfstate"
|
||||||
|
region = "us-east-1"
|
||||||
|
skip_credentials_validation = true
|
||||||
|
skip_metadata_api_check = true
|
||||||
|
skip_region_validation = true
|
||||||
|
force_path_style = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea (Source Control + CI/CD)
|
||||||
|
|
||||||
|
Gitea runs on its own Droplet with a dedicated managed Postgres. It hosts:
|
||||||
|
- Source code repositories
|
||||||
|
- Gitea Actions CI/CD pipelines
|
||||||
|
- Docker container registry (`registry.lunarfront.tech`)
|
||||||
|
|
||||||
|
Managed by Ansible. See `lunarfront-infra/ansible/roles/gitea/`.
|
||||||
|
|
||||||
|
### CI Pipeline (Gitea Actions)
|
||||||
|
|
||||||
|
On push to `main`:
|
||||||
|
|
||||||
|
1. Run lint + unit tests using the shared `ci-runner` image
|
||||||
|
2. Build Docker image, push to `registry.lunarfront.tech/ryan/lunarfront-app:{sha}`
|
||||||
|
3. Update the Helm chart `values.yaml` with the new image tag
|
||||||
|
4. ArgoCD detects the change and syncs all customer deployments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes (DOKS + ArgoCD)
|
||||||
|
|
||||||
|
All customer app instances run on a single DOKS cluster managed by ArgoCD (GitOps).
|
||||||
|
|
||||||
|
### Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/gitops
|
||||||
|
apps/
|
||||||
|
customer-acme/
|
||||||
|
values.yaml
|
||||||
|
customer-foo/
|
||||||
|
values.yaml
|
||||||
|
chart/
|
||||||
|
Chart.yaml
|
||||||
|
templates/
|
||||||
|
deployment.yaml
|
||||||
|
service.yaml
|
||||||
|
ingress.yaml
|
||||||
|
job-migrate.yaml # runs drizzle-kit migrate on deploy
|
||||||
|
secret.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Customer values.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
customer: acme
|
||||||
|
subdomain: acme
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: registry.lunarfront.tech/ryan/lunarfront-app
|
||||||
|
tag: "abc123"
|
||||||
|
|
||||||
|
database:
|
||||||
|
url: "postgresql://lunarfront:pass@db-host:25060/customer_acme?sslmode=require"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
url: "rediss://..."
|
||||||
|
|
||||||
|
env:
|
||||||
|
JWT_SECRET: "..."
|
||||||
|
NODE_ENV: production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Customer
|
||||||
|
|
||||||
|
1. `CREATE DATABASE customer_x;` on the shared managed Postgres
|
||||||
|
2. Add `gitops/apps/customer-x/values.yaml`
|
||||||
|
3. Push — ArgoCD syncs, migration Job runs, instance is live
|
||||||
|
4. Add DNS CNAME in Terraform
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Strategy
|
||||||
|
|
||||||
|
One managed Postgres cluster shared across all customers. Each customer gets their own isolated database (`CREATE DATABASE customer_x`). No cross-customer queries are possible at the database level.
|
||||||
|
|
||||||
|
- New customer = new database, no new infrastructure cost
|
||||||
|
- Managed DO Postgres handles backups, failover, and SSL
|
||||||
|
- Resize the cluster as total load grows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day-to-Day Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Provision infrastructure from scratch
|
||||||
|
terraform init && terraform apply
|
||||||
|
|
||||||
|
# Configure Gitea server
|
||||||
|
ansible-playbook ansible/gitea.yml -i ansible/inventory.ini
|
||||||
|
|
||||||
|
# Normal deploy flow — push to main
|
||||||
|
git push origin main
|
||||||
|
# → Gitea Actions: lint + test + build image
|
||||||
|
# → Image pushed to registry
|
||||||
|
# → Helm chart values updated
|
||||||
|
# → ArgoCD syncs all customer deployments automatically
|
||||||
|
|
||||||
|
# Add a new customer
|
||||||
|
# 1. Create database
|
||||||
|
psql $DATABASE_URL -c "CREATE DATABASE customer_x;"
|
||||||
|
# 2. Add values file, push
|
||||||
|
git add gitops/apps/customer-x/values.yaml && git push
|
||||||
|
# Done — ArgoCD handles the rest
|
||||||
|
```
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@lunarfront/shared": "workspace:*",
|
"@lunarfront/shared": "workspace:*",
|
||||||
"bcrypt": "^6",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.38",
|
"drizzle-orm": "^0.38",
|
||||||
"fastify": "^5",
|
"fastify": "^5",
|
||||||
"fastify-plugin": "^5",
|
"fastify-plugin": "^5",
|
||||||
@@ -32,10 +33,9 @@
|
|||||||
"zod": "^4"
|
"zod": "^4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/node": "^22",
|
||||||
"drizzle-kit": "^0.30",
|
"drizzle-kit": "^0.30",
|
||||||
"pino-pretty": "^13",
|
"pino-pretty": "^13",
|
||||||
"@types/node": "^22",
|
"typescript": "^5"
|
||||||
"@types/bcrypt": "^5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ async function seed() {
|
|||||||
const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin1234'
|
const adminPassword = process.env.ADMIN_PASSWORD ?? 'admin1234'
|
||||||
const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'`
|
const [adminUser] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'`
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
const bcrypt = await import('bcrypt')
|
const bcrypt = await import('bcryptjs')
|
||||||
const hashedPw = await (bcrypt.default || bcrypt).hash(adminPassword, 10)
|
const hashedPw = await bcrypt.hash(adminPassword, 10)
|
||||||
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id`
|
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id`
|
||||||
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
|
||||||
if (adminRole) {
|
if (adminRole) {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator'
|
||||||
|
import postgres from 'postgres'
|
||||||
import rateLimit from '@fastify/rate-limit'
|
import rateLimit from '@fastify/rate-limit'
|
||||||
import { databasePlugin } from './plugins/database.js'
|
import { databasePlugin } from './plugins/database.js'
|
||||||
import { redisPlugin } from './plugins/redis.js'
|
import { redisPlugin } from './plugins/redis.js'
|
||||||
@@ -8,6 +11,7 @@ import { authPlugin } from './plugins/auth.js'
|
|||||||
import { devAuthPlugin } from './plugins/dev-auth.js'
|
import { devAuthPlugin } from './plugins/dev-auth.js'
|
||||||
import { storagePlugin } from './plugins/storage.js'
|
import { storagePlugin } from './plugins/storage.js'
|
||||||
import { healthRoutes } from './routes/v1/health.js'
|
import { healthRoutes } from './routes/v1/health.js'
|
||||||
|
import { versionRoutes } from './routes/v1/version.js'
|
||||||
import { authRoutes } from './routes/v1/auth.js'
|
import { authRoutes } from './routes/v1/auth.js'
|
||||||
import { accountRoutes } from './routes/v1/accounts.js'
|
import { accountRoutes } from './routes/v1/accounts.js'
|
||||||
import { inventoryRoutes } from './routes/v1/inventory.js'
|
import { inventoryRoutes } from './routes/v1/inventory.js'
|
||||||
@@ -92,6 +96,7 @@ export async function buildApp() {
|
|||||||
|
|
||||||
// Core routes — always available
|
// Core routes — always available
|
||||||
await app.register(healthRoutes, { prefix: '/v1' })
|
await app.register(healthRoutes, { prefix: '/v1' })
|
||||||
|
await app.register(versionRoutes, { prefix: '/v1' })
|
||||||
await app.register(authRoutes, { prefix: '/v1' })
|
await app.register(authRoutes, { prefix: '/v1' })
|
||||||
await app.register(accountRoutes, { prefix: '/v1' })
|
await app.register(accountRoutes, { prefix: '/v1' })
|
||||||
await app.register(rbacRoutes, { prefix: '/v1' })
|
await app.register(rbacRoutes, { prefix: '/v1' })
|
||||||
@@ -138,7 +143,20 @@ export async function buildApp() {
|
|||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
const connectionString = process.env.DATABASE_URL
|
||||||
|
if (!connectionString) throw new Error('DATABASE_URL is required')
|
||||||
|
|
||||||
|
const migrationsFolder = process.env.MIGRATIONS_DIR ?? './src/db/migrations'
|
||||||
|
const sql = postgres(connectionString, { max: 1 })
|
||||||
|
const db = drizzle(sql)
|
||||||
|
|
||||||
|
await migrate(db, { migrationsFolder })
|
||||||
|
await sql.end()
|
||||||
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
await runMigrations()
|
||||||
const app = await buildApp()
|
const app = await buildApp()
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? '8000', 10)
|
const port = parseInt(process.env.PORT ?? '8000', 10)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcryptjs'
|
||||||
import { users } from '../db/schema/users.js'
|
import { users } from '../db/schema/users.js'
|
||||||
import { RbacService } from '../services/rbac.service.js'
|
import { RbacService } from '../services/rbac.service.js'
|
||||||
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
|
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcryptjs'
|
||||||
import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas'
|
import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas'
|
||||||
import { users } from '../../db/schema/users.js'
|
import { users } from '../../db/schema/users.js'
|
||||||
|
|
||||||
|
|||||||
7
packages/backend/src/routes/v1/version.ts
Normal file
7
packages/backend/src/routes/v1/version.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
|
|
||||||
|
export const versionRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.get('/version', async (_request, reply) => {
|
||||||
|
reply.send({ version: process.env.APP_VERSION ?? 'dev' })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { userRoles } from '../db/schema/rbac.js'
|
|||||||
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
||||||
import type { PaginationInput } from '@lunarfront/shared/schemas'
|
import type { PaginationInput } from '@lunarfront/shared/schemas'
|
||||||
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'
|
import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
// --- Encryption key held in memory ---
|
// --- Encryption key held in memory ---
|
||||||
let derivedKey: Buffer | null = null
|
let derivedKey: Buffer | null = null
|
||||||
|
|||||||
Reference in New Issue
Block a user