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:
Ryan Moon
2026-04-01 19:50:37 -05:00
parent ffef4c8727
commit c2b1073fef
15 changed files with 419 additions and 26 deletions

View File

@@ -56,11 +56,10 @@ src/
### Request Flow
1. Fastify receives request
2. `onRequest` hook sets `companyId` from header
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
4. `requirePermission` preHandler checks user has required permission slug
5. Route handler validates input with Zod, calls service, returns response
6. Error handler catches typed errors and maps to HTTP status codes
2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
3. `requirePermission` preHandler checks user has required permission slug
4. Route handler validates input with Zod, calls service, returns response
5. Error handler catches typed errors and maps to HTTP status codes
### Permission Inheritance
@@ -109,9 +108,9 @@ src/
| Theme | Zustand store, persisted to localStorage |
| 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

193
docs/deployment.md Normal file
View 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
```