Skip to content

Latest commit

 

History

History
492 lines (351 loc) · 20.3 KB

File metadata and controls

492 lines (351 loc) · 20.3 KB

Setup Guide — kalandra.tech

Step-by-step guide for local development, testing, and deployment infrastructure.

For architecture, tech stack, and decision log, see the Project page.


Table of Contents


1. Local Development

1.1 Prerequisites

1.2 Install Dependencies

Run once after cloning (or when dependencies change):

npm install            # Installs root + frontend dependencies (via postinstall)

1.3 Start Everything

npm run aspire   # Installs deps, starts PostgreSQL + local Supabase, then launches the Aspire AppHost

The Aspire AppHost orchestrates the API and frontend and exposes the Aspire dashboard with per-resource logs, distributed traces (OpenTelemetry), metrics, and structured logs in one UI. The dashboard and frontend URLs are printed (clickably, in supporting terminals) on startup. Production telemetry is routed to Sentry via the OTEL bridge (see Observability); nothing about that changes when running locally under Aspire.

Supabase containers stay owned by the Supabase CLI — Ctrl+C-ing the AppHost would otherwise leak them. The AppHost surfaces the API / Studio / Mailpit endpoints on the dashboard as external services (display-only, no lifecycle), so you get one-click access without the cleanup risk. Use supabase stop to halt the stack, or supabase stop --no-backup to discard data.

Application data lives in a separate Postgres owned by Aspire (AddPostgres) — not Supabase's bundled DB. Each worktree gets its own container and named volume (kalandra-pgdata-<repo-folder>); Ctrl+C stops the container, the volume persists across runs.

1.4 Local Supabase

The project includes a supabase/config.toml that configures a local Supabase instance with email/password auth (no email confirmation required). On first run, supabase start downloads the required Docker images (~2-3 min).

Local services:

Service URL
API gateway http://localhost:54321
Studio dashboard http://localhost:54323
Inbucket (email) http://localhost:54324

Local credentials (well-known dev values, not secrets):

  • Publishable key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0

1.5 Frontend Environment

The committed frontend/.env has local Supabase defaults — ready to use out of the box.

To override any frontend env vars locally, create frontend/.env.local (gitignored). Common overrides:

PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
PUBLIC_TURNSTILE_SITE_KEY=your-real-site-key

PUBLIC_API_URL is intentionally empty in dev — the Aspire AppHost forces it to "" so all API calls flow through Vite's /api proxy (see astro.config.mjs). Setting it in .env.local has no effect under npm run aspire. It's only meaningful at production build time (e.g. https://api.kalandra.tech).

Cloudflare Turnstile (CAPTCHA)

The committed .env uses Cloudflare's always-pass test keys so the form works locally without a real Turnstile widget. The backend appsettings.json uses the matching always-pass test secret (1x0000000000000000000000000000000AA).

To test with a real widget locally, override in .env.local (frontend) and user-secrets (backend):

# frontend/.env.local
PUBLIC_TURNSTILE_SITE_KEY=your-real-site-key

# backend — via environment variable or appsettings override
Turnstile__SecretKey=your-real-secret-key

1.6 Stopping Services

Aspire owns the application Postgres — Ctrl+C-ing the AppHost stops the container, but the named volume (kalandra-pgdata-<repo-folder>) keeps the data for next time.

Supabase is shared machine-wide and outlives the AppHost. To halt it:

supabase stop                 # Stop containers (preserves data)
supabase stop --no-backup     # Stop and wipe Supabase state

1.7 Parallel Worktrees

Just run npm run aspire in each. The AppHost walks the dashboard / OTLP ports up from their defaults until it finds free ones, so the first instance is at 15036, the second at 15037, etc. dcp handles API and frontend ports the same way internally. The startup output prints clickable URLs for the dashboard and frontend.

The application Postgres is per-worktree (Aspire scopes the data volume to the repo-folder name), so each worktree has its own DB state. Supabase is shared (one machine-level instance), so auth users and storage objects are visible across worktrees — that's fine for fixtures.


2. Running Tests

npm test               # Runs all tests: backend + frontend + E2E

2.1 Backend Integration Tests

Requires Docker (Testcontainers spins up a real PostgreSQL container):

dotnet test

2.2 Frontend Page Tests

Builds the static site, serves it, and verifies page rendering, navigation, i18n, and dark mode:

npm --prefix frontend test  # Installs Playwright browsers automatically

2.3 E2E Tests

Runs Playwright against the full stack (frontend + backend + DB):

npm run test:e2e

3. Infrastructure Setup

This section covers the one-time setup needed to host this project yourself.

3.1 Supabase Project

Create Project

  1. Go to supabase.com and create a new project
  2. Note these values from Settings → API:
    • Project URL (e.g., https://abcdef.supabase.co)
    • Publishable key (safe for browser)
    • JWT keys — the backend fetches these automatically via JWKS; no manual configuration needed

Configure OAuth Provider (Google)

  1. In Supabase dashboard: Authentication → Providers → Google
  2. Enable Google provider
  3. Create OAuth credentials in Google Cloud Console:
    • Application type: Web application
    • Authorized redirect URI: https://<your-project-ref>.supabase.co/auth/v1/callback
  4. Paste the Client ID and Client Secret into Supabase
  5. Optionally enable GitHub as a second provider (same flow)

Configure Redirect URLs

In Authentication → URL Configuration:

  • Site URL: https://www.kalandra.tech
  • Redirect URLs (add all):
    • https://www.kalandra.tech/**
    • https://kalandra.tech/**
    • http://localhost:4321/** (for local development)

Create Storage Bucket

  1. In Supabase dashboard: Storage → New bucket
  2. Name: job-offer-attachments
  3. Public: off
  4. File size limit: 15 MB
  5. Allowed MIME types: application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.openxmlformats-officedocument.presentationml.presentation, text/plain, image/png, image/jpeg, image/webp

Locally, the bucket is auto-created by supabase start from the [storage.buckets.job-offer-attachments] section in supabase/config.toml.

Set Up Admin User

After signing in for the first time, assign the admin role:

-- Run in Supabase SQL Editor to set admin via app_metadata
UPDATE auth.users
SET raw_app_meta_data = raw_app_meta_data || '{"roles": ["admin"]}'::jsonb
WHERE email = 'your@email.com';

3.2 Oracle Cloud VM

Create Always Free VM

  1. Sign up for Oracle Cloud Free Tier
  2. Create a Compute instance:
    • Shape: VM.Standard.A1.Flex (ARM, 4 OCPU / 24 GB RAM — Always Free)
    • Image: Oracle Linux 8 aarch64 (ARM image for A1 shape)
    • Add your SSH public key
  3. Note the public IP address

Install podman

Oracle Linux ships with rootless podman. The podman-docker shim makes the docker CLI an alias for podman so existing scripts work unchanged.

sudo dnf install -y podman podman-docker

Authenticate to GHCR

The API image is pulled from GitHub Container Registry. Create a GitHub Personal Access Token with read:packages scope and log in once:

echo <GITHUB_PAT> | podman login ghcr.io -u <GITHUB_USERNAME> --password-stdin

Configure firewall

# Open ports 80 (HTTP), 443 (HTTPS), 8080 (API)
sudo iptables -I INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT -p tcp --dport 443 -j ACCEPT
sudo iptables -I INPUT -p tcp --dport 8080 -j ACCEPT
sudo netfilter-persistent save

Also add ingress rules in OCI Console:

  • Networking → Virtual Cloud Networks → Security Lists
  • Add ingress rules for ports 80, 443, 8080

API containers (Quadlet + systemd)

The API runs in two slots — kalandra-api-blue (port 8080) and kalandra-api-green (port 8081) — managed as systemd user services via Quadlet. At most one slot is enabled and running at a time; the CI/CD deploy script swaps slots on each release. Caddy (set up in §3.3) proxies to whichever port the active slot is on.

Nothing to do here manually. The Quadlet unit files live in infra/quadlet/ and are pushed to the VM by the CI/CD deploy job, which also writes ~/kalandra-api.env from GitHub secrets, runs systemctl --user daemon-reload, removes any leftover ad-hoc containers, and starts the chosen slot. The first deploy after this VM is provisioned will fully bootstrap the setup.

About linger: the deploy job runs sudo loginctl enable-linger opc as part of its bootstrap. Linger keeps the opc user's systemd instance running across SSH logouts and across reboots, which is what makes the enabled slot come back automatically when the VM restarts. Without it, the user manager would only exist while someone is logged in, and any --user service would die on logout. The setting is idempotent and safe to re-apply on every deploy.

3.3 Reverse Proxy (Caddy)

Caddy serves as the HTTPS reverse proxy in front of the backend API. TLS is handled via a Cloudflare Origin Certificate (not Let's Encrypt), since api.kalandra.tech is proxied through Cloudflare. Like the API, Caddy runs as a rootless Quadlet container under the opc user, managed by systemd --user. The Quadlet unit lives at infra/quadlet/caddy.container and is synced to the VM by the deploy job.

3.3.1 Create Cloudflare Origin Certificate

  1. Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate
  2. Hostnames: api.kalandra.tech
  3. Validity: 15 years (default)
  4. Format: PEM
  5. Copy both the certificate and private key

3.3.2 Upload Certificate to VM

mkdir -p ~/certs
nano ~/certs/origin.pem       # paste the certificate, Ctrl+O to save, Ctrl+X to exit
nano ~/certs/origin-key.pem   # paste the private key
chmod 600 ~/certs/origin-key.pem

3.3.3 Caddy container

Nothing to do here manually. The deploy job:

  • Sets net.ipv4.ip_unprivileged_port_start=80 (via /etc/sysctl.d/) so the rootless container can bind ports 80/443
  • Seeds an initial ~/Caddyfile if none exists
  • Starts caddy.service via systemctl --user enable --now
  • Rewrites ~/Caddyfile and reloads Caddy gracefully (podman exec caddy caddy reload) on every slot swap

Caddy's /data and /config directories are persisted across restarts via two podman-managed volumes (caddy-data and caddy-config).

3.3.4 Configure Cloudflare SSL Mode

In Cloudflare dashboard → SSL/TLS → Overview, set the mode to Full (strict). This ensures Cloudflare validates the origin certificate when connecting to your VM.

3.4 Enable IPv6 on the VCN

Oracle Cloud VCNs are IPv4-only by default. IPv6 is required for the backend to reach Supabase PostgreSQL (which resolves to an IPv6 address). All steps are in the OCI Console.

3.4.1 Add IPv6 to VCN

  1. Networking → Virtual Cloud Networks → click your VCN
  2. Click Add IPv6 CIDR Block/Prefix
  3. Choose Oracle-allocated IPv6 /56 prefix
  4. Click Add

3.4.2 Add IPv6 to Subnet

  1. Inside the VCN, go to Subnets → click your subnet
  2. Click Add IPv6 CIDR Block/Prefix
  3. Choose a /64 from the VCN's /56 allocation
  4. Click Add

3.4.3 Add IPv6 Route

  1. Inside the VCN, go to Route Tables → click the subnet's route table
  2. Add Route Rule:
    • Destination: ::/0
    • Target Type: Internet Gateway
    • Target: your existing Internet Gateway
  3. Click Add

3.4.4 Add IPv6 Security Rules

In Security Lists (or your Network Security Group), add:

Egress (required — outbound to Supabase):

  • Stateful: Yes
  • Destination: ::/0
  • Protocol: TCP
  • Destination Port Range: All (or 5432, 443 for minimal access)

Ingress (optional — if you want the API reachable over IPv6):

  • Source: ::/0
  • Protocol: TCP
  • Destination Port Range: 80, 443, 8080

3.4.5 Assign IPv6 Address to the VM

  1. Compute → Instances → click your instance
  2. Under Resources → Attached VNICs → click the VNIC
  3. Under Resources → IPv6 Addresses → click Assign IPv6 Address
  4. Choose Automatically assign from subnet prefix
  5. Click Assign

3.4.6 Verify IPv6 on the VM

SSH into the VM (ssh opc@<public-ip>) and verify:

# Verify IPv6 is not disabled (should return 0)
sysctl net.ipv6.conf.all.disable_ipv6

# Confirm a GUA (2603:...) address is assigned
ip -6 addr show

# Test outbound IPv6 (TCP — ICMP ping may be blocked by OCI)
curl -6 -v --connect-timeout 5 https://ipv6.google.com 2>&1 | head -5

Oracle Linux uses firewalld. If ICMPv6 is needed for debugging:

sudo firewall-cmd --add-protocol=ipv6-icmp --permanent
sudo firewall-cmd --reload

Note: No Docker IPv6 configuration is needed — the backend container runs with --network host, so it shares the host's IPv6 stack directly.

3.5 DNS

In Cloudflare DNS, add an A record for api.kalandra.tech pointing to your OCI VM's public IP. Keep it Proxied (orange cloud) — Cloudflare handles public TLS, Caddy uses the origin certificate for the Cloudflare-to-origin connection.


4. CI/CD Configuration

4.1 GitHub Repository Secrets

Add these secrets in Settings → Secrets and Variables → Actions:

Secret Value
OCI_HOST Your OCI VM public IP
OCI_USERNAME SSH username (opc for Oracle Linux)
OCI_SSH_KEY Private SSH key for the VM
DB_CONNECTION_STRING Host=db.<project-ref>.supabase.co;Database=postgres;Username=postgres;Password=<DB_PASSWORD>;Port=5432
SUPABASE_PROJECT_URL https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY Service role key from Supabase dashboard (Settings → API) — used by the backend for storage uploads
SUPABASE_PUBLISHABLE_KEY Publishable key from Supabase dashboard (mapped to PUBLIC_SUPABASE_PUBLISHABLE_KEY at frontend build time)
TURNSTILE_SECRET_KEY Cloudflare Turnstile secret key (from Turnstile dashboard) — used by backend to verify CAPTCHA tokens
TURNSTILE_SITE_KEY Cloudflare Turnstile site key (public, mapped to PUBLIC_TURNSTILE_SITE_KEY at frontend build time)
BACKEND_SENTRY_DSN DSN from the Sentry backend (.NET) project — written to Sentry__Dsn at deploy time. Required in production; the API throws on startup if it's missing.

The frontend Sentry DSN is committed in frontend/.env — browser DSNs are public credentials (protected by the per-project Allowed Domains list in Sentry, not by secrecy) so a GitHub secret adds no value.

4.2 GitHub Actions Environment

Create a production environment in Settings → Environments:

  • Add protection rules (optional): require approval for deployments

4.3 Container Registry Auth

The CI/CD uses GitHub Container Registry (GHCR). The GITHUB_TOKEN is automatic for the build/push step. The OCI VM also pulls from GHCR — see Authenticate to GHCR under §3.2 for the manual podman login step.


5. Observability

Errors, structured logs, traces, and session replays are sent to Sentry. The backend uses Sentry.AspNetCore + Sentry.OpenTelemetry to bridge its existing OTEL pipeline; the frontend uses @sentry/browser (npm SDK), dynamic-imported from src/lib/observability.ts so the chunk tree-shakes out when no DSN is configured.

5.1 Sentry Projects

Create two separate projects in your Sentry organisation — the platforms differ, so the DSNs and source-map / release tooling won't be interchangeable:

Project Platform DSN goes to
Backend API .NET → ASP.NET Core GitHub secret BACKEND_SENTRY_DSN
Frontend site JavaScript → Browser frontend/.env (committed, public credential)

For each project:

  1. Sentry dashboard → Projects → Create Project → pick the platform above.
  2. Copy the DSN from the project's Settings → Client Keys (DSN) page.
  3. On the same page, set Allowed Domains to https://www.kalandra.tech, http://localhost:*, http://127.0.0.1:*. This is what protects the public frontend DSN from being abused by anyone who scrapes it from the bundle.
  4. Paste the backend DSN into the matching GitHub repository secret (§4.1); paste the frontend DSN into frontend/.env.

Sentry's free tier is sufficient for low-traffic personal projects; bump it later if you outgrow the event quota.

5.2 Environments and CI noise

The frontend tags Sentry events with an environment derived from Vite's build mode by default (development in astro dev, production in astro build). CI Playwright jobs override this via PUBLIC_SENTRY_ENVIRONMENT=ci in .github/workflows/ci-cd.yml so their events filter out of production dashboards — add !environment:ci as a saved filter in Sentry for clean prod views.

5.3 Local Development

You don't need a Sentry DSN to run the stack locally — but the frontend DSN is committed, so by default any npm run aspire session will emit environment: development events to Sentry. That's intentional (lets you verify changes against real Sentry). To opt out locally, set PUBLIC_SENTRY_DSN= in frontend/.env.local.

The backend, by contrast, only requires a DSN when ASPNETCORE_ENVIRONMENT=Production. In Development (the AppHost default) it's optional; a missing configuration won't break local runs. To exercise the production path locally:

dotnet user-secrets --project backend/src/Kalandra.Api set "Sentry:Dsn" "<your-dsn>"