Step-by-step guide for local development, testing, and deployment infrastructure.
For architecture, tech stack, and decision log, see the Project page.
- 1. Local Development
- 2. Running Tests
- 3. Infrastructure Setup
- 4. CI/CD Configuration
- 5. Observability
- .NET 10 SDK
- Docker (for PostgreSQL + local Supabase)
- Node.js 22+
Run once after cloning (or when dependencies change):
npm install # Installs root + frontend dependencies (via postinstall)npm run aspire # Installs deps, starts PostgreSQL + local Supabase, then launches the Aspire AppHostThe 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.
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
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_URLis intentionally empty in dev — the Aspire AppHost forces it to""so all API calls flow through Vite's/apiproxy (seeastro.config.mjs). Setting it in.env.localhas no effect undernpm run aspire. It's only meaningful at production build time (e.g.https://api.kalandra.tech).
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
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 stateJust 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.
npm test # Runs all tests: backend + frontend + E2ERequires Docker (Testcontainers spins up a real PostgreSQL container):
dotnet testBuilds the static site, serves it, and verifies page rendering, navigation, i18n, and dark mode:
npm --prefix frontend test # Installs Playwright browsers automaticallyRuns Playwright against the full stack (frontend + backend + DB):
npm run test:e2eThis section covers the one-time setup needed to host this project yourself.
- Go to supabase.com and create a new project
- 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
- Project URL (e.g.,
- In Supabase dashboard: Authentication → Providers → Google
- Enable Google provider
- Create OAuth credentials in Google Cloud Console:
- Application type: Web application
- Authorized redirect URI:
https://<your-project-ref>.supabase.co/auth/v1/callback
- Paste the Client ID and Client Secret into Supabase
- Optionally enable GitHub as a second provider (same flow)
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)
- In Supabase dashboard: Storage → New bucket
- Name:
job-offer-attachments - Public: off
- File size limit:
15 MB - 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 startfrom the[storage.buckets.job-offer-attachments]section insupabase/config.toml.
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';- Sign up for Oracle Cloud Free Tier
- 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
- Shape:
- Note the public IP address
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-dockerThe 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# 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 saveAlso add ingress rules in OCI Console:
- Networking → Virtual Cloud Networks → Security Lists
- Add ingress rules for ports 80, 443, 8080
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 opcas part of its bootstrap. Linger keeps theopcuser'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--userservice would die on logout. The setting is idempotent and safe to re-apply on every deploy.
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.
- Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate
- Hostnames:
api.kalandra.tech - Validity: 15 years (default)
- Format: PEM
- Copy both the certificate and private key
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.pemNothing 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
~/Caddyfileif none exists - Starts
caddy.serviceviasystemctl --user enable --now - Rewrites
~/Caddyfileand 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).
In Cloudflare dashboard → SSL/TLS → Overview, set the mode to Full (strict). This ensures Cloudflare validates the origin certificate when connecting to your VM.
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.
- Networking → Virtual Cloud Networks → click your VCN
- Click Add IPv6 CIDR Block/Prefix
- Choose Oracle-allocated IPv6 /56 prefix
- Click Add
- Inside the VCN, go to Subnets → click your subnet
- Click Add IPv6 CIDR Block/Prefix
- Choose a
/64from the VCN's/56allocation - Click Add
- Inside the VCN, go to Route Tables → click the subnet's route table
- Add Route Rule:
- Destination:
::/0 - Target Type: Internet Gateway
- Target: your existing Internet Gateway
- Destination:
- Click Add
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
- Compute → Instances → click your instance
- Under Resources → Attached VNICs → click the VNIC
- Under Resources → IPv6 Addresses → click Assign IPv6 Address
- Choose Automatically assign from subnet prefix
- Click Assign
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 -5Oracle Linux uses firewalld. If ICMPv6 is needed for debugging:
sudo firewall-cmd --add-protocol=ipv6-icmp --permanent
sudo firewall-cmd --reloadNote: No Docker IPv6 configuration is needed — the backend container runs with
--network host, so it shares the host's IPv6 stack directly.
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.
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.
Create a production environment in Settings → Environments:
- Add protection rules (optional): require approval for deployments
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.
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.
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:
- Sentry dashboard → Projects → Create Project → pick the platform above.
- Copy the DSN from the project's Settings → Client Keys (DSN) page.
- 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. - 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.
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.
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>"