Appearance
🔄 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(andwww.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.
| Component | Solution | Cost |
|---|---|---|
| VPS (all backend services) | Hetzner CX22 | ~$5/mo |
| Media / file storage | Cloudflare R2 | Free ≤10 GB, $0.015/GB after |
| Landing page | GoDaddy | already paid |
| DNS | GoDaddy | included 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
| Service | RAM |
|---|---|
| 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 | bashDashboard 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 build→dist/) - 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.appandwww.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/indexPosthooks called on create/update). - Qdrant: run as another Coolify-managed Docker container (
qdrant/qdrant, internal hostnameqdrant, port 6333, ~150 MB RAM idle). Like the databases, it must NOT be exposed to the internet.
Embedding providers
Selected with EMBEDDINGS_PROVIDER:
| Provider | Model | Dims | Needs | Notes |
|---|---|---|---|---|
fastembed (default) | BAAI/bge-small-en-v1.5 (local ONNX) | 384 | nothing | Model (~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. |
openai | text-embedding-3-small | 1536 | OPENAI_API_KEY | Legacy path; ~$0.00002 per embed call. |
none | — | — | — | Disables 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 usesembedTexton both sides.
Collection format
One Qdrant collection per entity, namespaced by provider/model so vectors from different models never mix:
| Entity | fastembed collection | openai (legacy) collection |
|---|---|---|
| Talent profiles | talent_profiles_bge_small_en_v15 | talent_profiles |
| Producer profiles | producer_profiles_bge_small_en_v15 | producer_profiles |
| Open jobs | jobs_bge_small_en_v15 | jobs |
| Published posts | posts_bge_small_en_v15 | posts |
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-runIf 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.
| R2 | AWS S3 | |
|---|---|---|
| Storage | $0.015/GB/mo | $0.023/GB/mo |
| Egress | Free | $0.09/GB |
| Free tier | 10 GB + 1M writes + 10M reads/mo | 5 GB for 12 months only |
Setup
- Create a Cloudflare account → R2 → Create bucket
- Generate an API token with R2 read/write permissions
- 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-mediaUsage in Express (AWS SDK v3)
bash
npm install @aws-sdk/client-s3javascript
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.jpgFor 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
- Create a Resend account → add and verify the sending domain (the domain in
EMAIL_FROM, e.g.castyou.comorcastyouapp.com). - 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 ~allstyle) — do not add a secondv=spf1TXT, which breaks SPF. - DKIM (CNAME/TXT) — signs outbound mail so it isn't marked spam.
- Optionally a custom Return-Path (CNAME) for full alignment.
- SPF (TXT) — authorizes Resend to send for the domain. If a Google Workspace SPF already exists, merge into one record (
- (Recommended) a DMARC TXT record on the domain once SPF+DKIM pass.
- 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 emailsIn 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
| Plan | Cost | Includes |
|---|---|---|
| Free | $0 | 3,000 emails/mo, 100/day, 1 domain |
| Pro | $20/mo | 50,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
*.mdDeploy Workflow
Initial setup (one time)
- Provision Hetzner CX22 with Ubuntu 24.04
- SSH in and run the Coolify install script
- Open Coolify dashboard at
http://<ip>:8000, create admin account - Add a server (localhost), connect GitHub via GitHub App
- Create databases: Postgres, MongoDB, Redis (one-click in Coolify)
- Create services: Express BE, Vite React — point each to its GitHub repo and branch
- Set environment variables per service in the Coolify dashboard
- 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 serviceOr 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:
| Type | Name | Value | Notes |
|---|---|---|---|
| A | @ | <hetzner-ip> | Apex / landing page |
| A | www | <hetzner-ip> | www.castyou.app → landing (or CNAME → castyou.app) |
| A | api | <hetzner-ip> | Express backend |
| A | i | <hetzner-ip> | Vite React app (i.castyou.app) |
| A | coolify | <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 linksSecurity 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-upgradesfor 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)
| Item | Cost |
|---|---|
| Hetzner CX22 | ~$5/mo |
| Cloudflare R2 (≤10 GB media) | $0 |
| Cloudflare R2 (beyond 10 GB) | $0.015/GB |
| GoDaddy landing page + DNS | already paid |
| Coolify | free (open source) |
| Monthly total (early stage) | ~$5/mo |
Upgrade triggers
| Symptom | Action | New cost |
|---|---|---|
| RAM > 3 GB consistently | Upgrade to CX32 (4 vCPU / 8 GB) | ~$8/mo |
| RAM > 6 GB consistently | Upgrade to CX42 (8 vCPU / 16 GB) | ~$15/mo |
| MongoDB / Postgres growing fast | Move DBs to managed service (Neon, Atlas) | +$8–25/mo |
| Need zero-downtime deploys | Add 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
- Log into Cloudflare → Add a site → enter your domain
- Cloudflare scans and imports all existing GoDaddy DNS records automatically — review them before proceeding
- Cloudflare provides two nameserver addresses (e.g.
arlo.ns.cloudflare.com,vera.ns.cloudflare.com) - Log into GoDaddy → My Domains → your domain → Manage DNS → Nameservers → change to Custom → paste Cloudflare's two nameservers
- Save — propagation takes 10 min to a few hours
- Verify in Cloudflare dashboard that the domain shows as Active
DNS records to set in Cloudflare
| Type | Name | Value | Proxy | Notes |
|---|---|---|---|---|
| A | @ | <hetzner-ip> | DNS only ☁️ | Root domain / landing page |
| A | www | <hetzner-ip> | DNS only ☁️ | www redirect |
| A | api | <hetzner-ip> | DNS only ☁️ | Express backend |
| A | i | <hetzner-ip> | DNS only ☁️ | Vite React app (i.castyou.app) |
| A | coolify | <hetzner-ip> | DNS only ☁️ | Coolify dashboard |
| MX | — | — | — | Email 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
| Plan | Cost | Includes |
|---|---|---|
| Business Starter | $6/user/mo | 30 GB storage, Gmail, Meet, Drive, Docs |
| Business Standard | $12/user/mo | 2 TB storage + meeting recordings |
For a small team, Business Starter at $6/user/mo is usually enough.
Steps
- Sign up at workspace.google.com → choose a plan → verify domain ownership
- Google provides MX records to add to your DNS
- 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- Also add the Google SPF record to prevent emails landing in spam:
Type Name Value
TXT @ v=spf1 include:_spf.google.com ~all- Optionally add DKIM and DMARC records (Google Workspace Admin → Apps → Gmail → Authenticate email)
- 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
| Item | Cost |
|---|---|
| Google Workspace Business Starter | $6/user/mo |
| 3-person team | ~$18/mo |
Migration Roadmap Summary
| Migration | Effort | Priority | Estimated saving / benefit |
|---|---|---|---|
| DNS → Cloudflare | Low (30 min) | Do soon | Better UI, DDoS protection, free |
| Landing page → Hetzner | Medium (2–4 hrs) | At next GoDaddy renewal | Save GoDaddy hosting fee |
| Email → Google Workspace | Low (1 hr setup) | When team grows | Professional email, $6/user/mo |
Recommended order: DNS first (easiest, enables everything else), then landing page (saves money), then email (when needed).