Skip to content

🔄 Synced from castyou-backend/docs/INFRASTRUCTURE.md — edit it there, not here.

CastYou — Infrastructure Documentation

Overview

All services run on a single Hetzner CX22 VPS managed by Coolify (self-hosted PaaS). Domain layout:

  • Landing: castyou.app (and www.castyou.app) — Next.js, served by Coolify
  • App: i.castyou.app — React + Vite, served by Coolify
  • Backend: api.castyou.app — Express + GraphQL, served by Coolify
  • Email: legal@castyouapp.com (separate domain kept for branding)

Media storage is handled by Cloudflare R2.

ComponentSolutionCost
VPS (all backend services)Hetzner CX22~$5/mo
Media / file storageCloudflare R2Free ≤10 GB, $0.015/GB after
Landing pageGoDaddyalready paid
DNSGoDaddyincluded with domain
Total~$5/mo to start

Server: Hetzner CX22

  • Specs: 2 vCPU, 4 GB RAM, 40 GB NVMe SSD, 20 TB/mo traffic
  • OS: Ubuntu 24.04 LTS
  • Cost: ~$5/mo flat rate — no usage-based billing, no surprise costs
  • Traffic overage: €1/TB (essentially never hit at early stage)
  • Upgrade path: CX22 → CX32 (4 vCPU / 8 GB, ~$8/mo) → CX42 (~$15/mo). Resize takes ~2 min from the Hetzner dashboard; Coolify and all containers come back up automatically.

Estimated RAM usage

ServiceRAM
Coolify + Traefik~1 GB
Express BE~150–300 MB
Vite React (static, served via nginx)~50 MB
Postgres~200 MB
MongoDB~200–300 MB
Redis~50 MB
Face-comparison sidecar (Python/dlib)~300 MB
Total used~2.1–2.4 GB of 4 GB
Free headroom~1.6 GB

Upgrade to CX32 when RAM usage consistently stays above 3 GB, or if you notice slowdowns during deployments.


PaaS Layer: Coolify

Coolify is a free, open-source self-hosted PaaS (Apache 2.0 license). It runs on Docker and provides a Heroku/Vercel-like experience on your own VPS.

Install (one command):

bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash

Dashboard is accessible at http://<your-server-ip>:8000 after install (~5 min setup).

What Coolify handles automatically

  • Reverse proxy via Traefik — routes api.yourdomain.com, app.yourdomain.com, etc. to the right container
  • SSL certificates via Let's Encrypt — automatic issuance and renewal
  • Git-based deploys — connect a GitHub/GitLab repo, Coolify builds and deploys on every push
  • One-click databases — Postgres, MongoDB, Redis provisioned with a few clicks, no manual Docker setup
  • Database backups — scheduled dumps to any S3-compatible storage (e.g. Cloudflare R2)
  • Logs and metrics — per-service logs and basic resource usage in the dashboard
  • Rollbacks — redeploy any past commit from the UI

Coolify does NOT handle

  • OS-level security updates (automate with unattended-upgrades)
  • Advanced monitoring/alerting (add Uptime Kuma or Better Stack separately if needed)
  • High availability / zero-downtime (single server — there will be brief downtime during server resizes)

Services

Express Backend

  • Deployed as a Dockerfile or via Nixpacks auto-detection
  • Accessible internally on Docker network, exposed via Traefik at api.castyou.app
  • Connects to Postgres, MongoDB, and Redis via internal Docker hostnames (e.g. postgres, mongodb, redis) — no exposed ports needed
  • Connects to Cloudflare R2 via the AWS S3 SDK

Vite React App

  • Built to static files (npm run builddist/)
  • Served by a lightweight nginx container (or Coolify's built-in static hosting)
  • Exposed via Traefik at i.castyou.app
  • No server-side rendering — pure static, very low resource usage

Landing Page

  • Self-hosted on the same Hetzner VPS via Coolify (Next.js standalone, port 3002)
  • Points to castyou.app and www.castyou.app
  • API calls go through a Next.js server action that proxies to api.castyou.app (so the BE URL stays out of the public bundle)

Databases

All databases run as Docker containers managed by Coolify on the same VPS. They are not exposed to the internet — only reachable from other containers on the same Docker network.

Postgres

  • Used for relational/structured data
  • Internal hostname: postgres (or the name set in Coolify)
  • Connection string: postgresql://user:password@postgres:5432/dbname

MongoDB

  • Used for document/flexible-schema data
  • Internal hostname: mongodb
  • Connection string: mongodb://user:password@mongodb:27017/dbname

Redis

  • Used for caching, sessions, queues
  • Internal hostname: redis
  • Connection string: redis://redis:6379

Backups

Configure in Coolify under each database → Backups tab:

  • Destination: Cloudflare R2 bucket (S3-compatible)
  • Schedule: daily at low-traffic hours
  • Retention: 7–14 days recommended

Embeddings & Vector Search (Qdrant + FastEmbed)

Semantic search (unified people/post search, AI job matching, follow suggestions) is powered by vector embeddings stored in Qdrant. Both pieces run locally on the VPS — no external AI API is required.

Architecture

                        index time                       query time
Postgres record ──► text serialiser ──► embedText() ┐   user query ──► embedQuery()
(talent/producer/    (embeddings.ts:      passage    │                    query
 job/post)            talentToText etc.)  embedding  │                  embedding
                                                     ▼                      │
                                          Qdrant collection ◄──── cosine ───┘
                                          (per entity, per provider)
  • Code: src/services/ai/embeddings.ts (providers + text serialisers), src/services/ai/vectorStore.ts (Qdrant REST wrapper), src/services/ai/matching.ts (indexTalent / indexProducer / indexJob / indexPost hooks called on create/update).
  • Qdrant: run as another Coolify-managed Docker container (qdrant/qdrant, internal hostname qdrant, port 6333, ~150 MB RAM idle). Like the databases, it must NOT be exposed to the internet.

Embedding providers

Selected with EMBEDDINGS_PROVIDER:

ProviderModelDimsNeedsNotes
fastembed (default)BAAI/bge-small-en-v1.5 (local ONNX)384nothingModel (~130 MB) auto-downloads to local_cache/ on first embed; first call takes a few seconds, subsequent calls are ms. ~200 MB extra RAM while loaded.
openaitext-embedding-3-small1536OPENAI_API_KEYLegacy path; ~$0.00002 per embed call.
noneDisables embeddings; search/matching fall back to keyword-only.

Two embedding functions exist because BGE models are asymmetric:

  • embedText(text) — documents/passages, used at index time.
  • embedQuery(text) — user search queries, used at query time (unified search). Document-to-document similarity (e.g. talent↔job matching) correctly uses embedText on both sides.

Collection format

One Qdrant collection per entity, namespaced by provider/model so vectors from different models never mix:

Entityfastembed collectionopenai (legacy) collection
Talent profilestalent_profiles_bge_small_en_v15talent_profiles
Producer profilesproducer_profiles_bge_small_en_v15producer_profiles
Open jobsjobs_bge_small_en_v15jobs
Published postsposts_bge_small_en_v15posts

Point format: Qdrant only accepts unsigned-int/UUID point ids, so each record's cuid is hashed to a deterministic UUID (pointId() in vectorStore.ts) and the original cuid travels in the payload as refId. Search results are mapped back to cuids via refId — application code never sees the UUIDs. Payloads also carry a few filterable fields (category, location, status…).

Collections are auto-created on first write with the active provider's vector size and cosine distance — no manual Qdrant setup.

Re-indexing

Vectors are written incrementally (profile update, post create, job create/update). A full rebuild is needed after:

  • switching EMBEDDINGS_PROVIDER (new collections start empty),
  • standing up a fresh Qdrant instance,
  • bulk data changes that bypass the API (imports, seeds).
bash
pnpm embeddings:reindex   # scripts/reindex-embeddings.ts — idempotent, safe to re-run

If embeddings/Qdrant are down or unconfigured, everything degrades gracefully: search becomes keyword-only (multi-term ILIKE), matching scores the vector factor as neutral. Nothing hard-fails.


Media Storage: Cloudflare R2

R2 is S3-compatible object storage with zero egress fees — files are served directly to users without passing through the VPS.

R2AWS S3
Storage$0.015/GB/mo$0.023/GB/mo
EgressFree$0.09/GB
Free tier10 GB + 1M writes + 10M reads/mo5 GB for 12 months only

Setup

  1. Create a Cloudflare account → R2 → Create bucket
  2. Generate an API token with R2 read/write permissions
  3. Add to Express BE environment variables:
env
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET_NAME=castyou-media

Usage in Express (AWS SDK v3)

bash
npm install @aws-sdk/client-s3
javascript
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'

const r2 = new S3Client({
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
})

// Upload a file
await r2.send(new PutObjectCommand({
  Bucket: process.env.R2_BUCKET_NAME,
  Key: 'uploads/photo.jpg',
  Body: fileBuffer,
  ContentType: 'image/jpeg',
}))

// Generate a public URL (if bucket is public)
// https://<account-id>.r2.cloudflarestorage.com/castyou-media/uploads/photo.jpg

For public media (profile photos, thumbnails), set the R2 bucket to public and serve files via the R2 public URL directly — no signed URLs needed, and no egress cost. For private files, use pre-signed URLs via @aws-sdk/s3-request-presigner.


Transactional Email: Resend

Status: planned, not yet built (Epic 39 in CASTYOU-ROADMAP.md). As of 2026-06-14 the backend sends no email — there is no provider, no src/services/email/, and no preference fields. This section documents the target infrastructure so it's provisioned correctly when the epic is implemented.

Distinct from team email. Two unrelated concerns share the word "email":

  • Receiving / human inboxes (@castyou.com, legal@castyouapp.com) — handled by MX records (see Future: Move Email to Google Workspace). Not what this section is about.
  • Sending / transactional (application-generated: application status, shortlist, support reply, hire, new follower, moderation suspension) — handled by Resend, an HTTP email API. Resend sends; it does not receive.

These do not conflict — MX records route inbound mail; Resend's domain records (SPF/DKIM) authorize outbound. Both can live on the same domain.

Architecture

Email is a delivery channel layered over the existing notification system (src/services/notifications/), not a parallel set of send calls. createNotification writes the in-app row as today, then — if the notification type declares an EMAIL channel, the user's preferences allow that category, and the call is not under admin impersonation (Epic 24) — enqueues a job onto a BullMQ email-send queue (Redis, already provisioned). A worker (src/workers/emailSender.ts) calls the Resend API. Send happens off the request path, so SMTP/API latency never blocks a GraphQL resolver. Everything is fail-open: email failures never affect the notification write or core business logic, mirroring the moderation/reel queues.

No new server-side component is needed — Resend is an external HTTP API and the queue runs on the existing Redis container. RAM impact is negligible (one more lightweight BullMQ worker in the existing Express process).

Setup

  1. Create a Resend account → add and verify the sending domain (the domain in EMAIL_FROM, e.g. castyou.com or castyouapp.com).
  2. Resend provides DNS records to add to the DNS provider (GoDaddy now, Cloudflare after migration):
    • SPF (TXT) — authorizes Resend to send for the domain. If a Google Workspace SPF already exists, merge into one record (v=spf1 include:_spf.google.com include:amazonses.com ~all style) — do not add a second v=spf1 TXT, which breaks SPF.
    • DKIM (CNAME/TXT) — signs outbound mail so it isn't marked spam.
    • Optionally a custom Return-Path (CNAME) for full alignment.
  3. (Recommended) a DMARC TXT record on the domain once SPF+DKIM pass.
  4. Generate a Resend API key, add to the Express BE env vars in Coolify (below).

Environment variables

env
RESEND_API_KEY=re_...                       # optional in dev (unset → emails logged to console, not sent)
EMAIL_FROM=CasTyou <noreply@castyou.com>    # must be on a Resend-verified domain
APP_BASE_URL=https://i.castyou.app          # for deep-link CTAs + signed unsubscribe links in emails

In dev/test with no RESEND_API_KEY, the email service logs the rendered message instead of sending — boot does not fail (same optional-in-dev pattern as R2/Stripe/OpenAI). In production the worker refuses to send (and retries/dead-letters the job) if the key is unset.

Cost

PlanCostIncludes
Free$03,000 emails/mo, 100/day, 1 domain
Pro$20/mo50,000 emails/mo, multiple domains, no daily cap

Free tier comfortably covers early-stage transactional volume; upgrade to Pro when daily sends approach the 100/day cap.


Dockerizing the Apps

Each app needs a Dockerfile in its repo root. Coolify can also auto-detect many frameworks via Nixpacks (no Dockerfile needed), but explicit Dockerfiles give more control.

Express BE — Dockerfile

dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000
CMD ["node", "src/index.js"]

If using TypeScript, add a build step:

dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Vite React — Dockerfile

dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

nginx.conf (needed for React Router client-side routing):

nginx
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    gzip on;
    gzip_types text/plain text/css application/json application/javascript;
}

.dockerignore (add to every repo)

node_modules
.env
.env.*
dist
.git
*.md

Deploy Workflow

Initial setup (one time)

  1. Provision Hetzner CX22 with Ubuntu 24.04
  2. SSH in and run the Coolify install script
  3. Open Coolify dashboard at http://<ip>:8000, create admin account
  4. Add a server (localhost), connect GitHub via GitHub App
  5. Create databases: Postgres, MongoDB, Redis (one-click in Coolify)
  6. Create services: Express BE, Vite React — point each to its GitHub repo and branch
  7. Set environment variables per service in the Coolify dashboard
  8. Add custom domains and let Coolify provision SSL automatically

Day-to-day deploys

bash
# Deploy Express BE
git push origin main   # Coolify auto-deploys on push

# Deploy Vite React
git push origin main   # same — each repo has its own Coolify service

Or trigger manually from the Coolify dashboard → service → Deploy.

Webhook-based deploy (optional, from terminal)

Each Coolify service has a deploy webhook URL. Trigger it manually:

bash
curl -X POST https://coolify.castyou.app/api/v1/deploy?uuid=<service-uuid>&force=false \
  -H "Authorization: Bearer <your-coolify-token>"

Useful for scripted deploys or wiring into a CI pipeline.

Rollback

In the Coolify dashboard → service → Deployments tab → click any past deployment → Redeploy.


DNS Setup (current — GoDaddy, domain castyou.app)

DNS is currently managed in GoDaddy. Point everything to the Hetzner VPS IP by adding A records in the GoDaddy DNS panel:

TypeNameValueNotes
A@<hetzner-ip>Apex / landing page
Awww<hetzner-ip>www.castyou.app → landing (or CNAME → castyou.app)
Aapi<hetzner-ip>Express backend
Ai<hetzner-ip>Vite React app (i.castyou.app)
Acoolify<hetzner-ip>Coolify dashboard (optional)

Coolify handles SSL for all subdomains automatically once DNS propagates (usually 5–30 min).

See Future: Move DNS to Cloudflare for the planned migration.


Environment Variables

Store all secrets in Coolify's per-service env var UI (never commit .env files). Coolify injects them at build and runtime.

Minimum vars for Express BE:

env
NODE_ENV=production
PORT=3000

# Postgres
DATABASE_URL=postgresql://user:password@postgres:5432/castyou

# MongoDB
MONGODB_URI=mongodb://user:password@mongodb:27017/castyou

# Redis
REDIS_URL=redis://redis:6379

# Cloudflare R2
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=castyou-media

# Embeddings / vector search (see "Embeddings & Vector Search" section)
EMBEDDINGS_PROVIDER=fastembed   # fastembed (default, local) | openai | none
QDRANT_URL=http://qdrant:6333
# OPENAI_API_KEY only needed when EMBEDDINGS_PROVIDER=openai (or for flier generation)

# Transactional email — Resend (see "Transactional Email: Resend" section). Planned (Epic 39).
# RESEND_API_KEY=re_...                       # optional in dev (unset → emails logged, not sent)
# EMAIL_FROM=CasTyou <noreply@castyou.com>    # must be on a Resend-verified domain
# APP_BASE_URL=https://i.castyou.app          # deep-link CTAs + unsubscribe links

Security Checklist

  • [ ] Change default SSH port or disable password auth (use SSH keys only)
  • [ ] Enable UFW firewall: allow ports 22 (SSH), 80 (HTTP), 443 (HTTPS), 8000 (Coolify dashboard)
  • [ ] Close port 8000 externally once you set up a Coolify domain with SSL
  • [ ] Enable unattended-upgrades for automatic OS security patches
  • [ ] Rotate all database passwords after initial setup
  • [ ] Never expose database ports (27017, 5432, 6379) to the internet — Coolify keeps them internal by default
  • [ ] Set up daily DB backups to R2 in Coolify

Cost Summary (current)

ItemCost
Hetzner CX22~$5/mo
Cloudflare R2 (≤10 GB media)$0
Cloudflare R2 (beyond 10 GB)$0.015/GB
GoDaddy landing page + DNSalready paid
Coolifyfree (open source)
Monthly total (early stage)~$5/mo

Upgrade triggers

SymptomActionNew cost
RAM > 3 GB consistentlyUpgrade to CX32 (4 vCPU / 8 GB)~$8/mo
RAM > 6 GB consistentlyUpgrade to CX42 (8 vCPU / 16 GB)~$15/mo
MongoDB / Postgres growing fastMove DBs to managed service (Neon, Atlas)+$8–25/mo
Need zero-downtime deploysAdd a second Hetzner server for DBs+$5/mo


Future Migrations

The following sections document planned migrations away from GoDaddy. None of these are urgent — do them when the time is right, not all at once.


Landing Page on Hetzner ✅ (done)

The landing page is hosted on the same Hetzner VPS as the rest of the stack, as a Coolify-managed Next.js standalone container served by Traefik at castyou.app and www.castyou.app. Port 3002 inside the container; Traefik handles TLS termination via Let's Encrypt.


Future: Move DNS to Cloudflare

Why: Cloudflare DNS is faster, has a much better UI, includes free DDoS protection, and you're already using Cloudflare for R2. Centralising DNS there removes GoDaddy from the critical path entirely.

When: Anytime — this is a low-risk, low-effort migration. Good to do before scaling traffic.

What changes

GoDaddy keeps the domain registration but stops managing DNS. Cloudflare becomes the authoritative DNS provider. All A records, MX records, and any other DNS entries move to Cloudflare's dashboard.

Steps

  1. Log into Cloudflare → Add a site → enter your domain
  2. Cloudflare scans and imports all existing GoDaddy DNS records automatically — review them before proceeding
  3. Cloudflare provides two nameserver addresses (e.g. arlo.ns.cloudflare.com, vera.ns.cloudflare.com)
  4. Log into GoDaddy → My Domains → your domain → Manage DNSNameservers → change to Custom → paste Cloudflare's two nameservers
  5. Save — propagation takes 10 min to a few hours
  6. Verify in Cloudflare dashboard that the domain shows as Active

DNS records to set in Cloudflare

TypeNameValueProxyNotes
A@<hetzner-ip>DNS only ☁️Root domain / landing page
Awww<hetzner-ip>DNS only ☁️www redirect
Aapi<hetzner-ip>DNS only ☁️Express backend
Ai<hetzner-ip>DNS only ☁️Vite React app (i.castyou.app)
Acoolify<hetzner-ip>DNS only ☁️Coolify dashboard
MXEmail is on castyouapp.com (separate domain) — set MX there, not here

Keep proxy OFF (grey cloud) for all A records pointing to Hetzner. Cloudflare's orange-cloud proxy intercepts SSL and conflicts with Coolify's Let's Encrypt cert provisioning. Only enable the proxy after you understand the tradeoff (you'd need to use Cloudflare's SSL instead of Let's Encrypt).

Cost after migration

Cloudflare DNS is free on the free plan. No change to overall cost.


Future: Move Email to Google Workspace

Why: Professional @castyou.com email addresses for the team, with Gmail's UI, Google Meet, Drive, and Docs included. Avoids dealing with a separate email hosting provider.

When: When the team needs shared inboxes, or when GoDaddy's bundled email (if any) is no longer sufficient.

Cost

PlanCostIncludes
Business Starter$6/user/mo30 GB storage, Gmail, Meet, Drive, Docs
Business Standard$12/user/mo2 TB storage + meeting recordings

For a small team, Business Starter at $6/user/mo is usually enough.

Steps

  1. Sign up at workspace.google.com → choose a plan → verify domain ownership
  2. Google provides MX records to add to your DNS
  3. In Cloudflare DNS (once migrated), add the MX records Google gives you:
Type   Name   Value                        Priority
MX     @      ASPMX.L.GOOGLE.COM           1
MX     @      ALT1.ASPMX.L.GOOGLE.COM      5
MX     @      ALT2.ASPMX.L.GOOGLE.COM      5
MX     @      ALT3.ASPMX.L.GOOGLE.COM      10
MX     @      ALT4.ASPMX.L.GOOGLE.COM      10
  1. Also add the Google SPF record to prevent emails landing in spam:
Type   Name   Value
TXT    @      v=spf1 include:_spf.google.com ~all
  1. Optionally add DKIM and DMARC records (Google Workspace Admin → Apps → Gmail → Authenticate email)
  2. Create user accounts in Google Workspace Admin console (e.g. hello@castyou.com, team@castyou.com)

If you are still on GoDaddy DNS when setting up Google Workspace, add the same MX and TXT records in GoDaddy's DNS panel instead. They migrate automatically when you move to Cloudflare.

Cost after migration

ItemCost
Google Workspace Business Starter$6/user/mo
3-person team~$18/mo

Migration Roadmap Summary

MigrationEffortPriorityEstimated saving / benefit
DNS → CloudflareLow (30 min)Do soonBetter UI, DDoS protection, free
Landing page → HetznerMedium (2–4 hrs)At next GoDaddy renewalSave GoDaddy hosting fee
Email → Google WorkspaceLow (1 hr setup)When team growsProfessional email, $6/user/mo

Recommended order: DNS first (easiest, enables everything else), then landing page (saves money), then email (when needed).