Deployment Guide
SaaSForge Agency has two deploy targets: a stateless Next.js frontend (Vercel-friendly) and a stateful Directus + Postgres + Redis stack (self-host or managed). This is the long-form guide covering production configurations, env vars, backups, monitoring, and verification.
For the quick walkthrough see Hosting & Deployment.
Pre-deploy checklist
Code & content
- Brand identity updated in
frontend/src/config/brand.ts(name, contact, social, trust) -
frontend/src/config/ui/pages.tsfallbacks reviewed - Logo assets replaced in
frontend/public/ - Directus content seeded for at least the home page in all 3 locales (or whichever locales you're shipping)
- All legal pages (
legal_pages) populated and reviewed - Site settings singleton populated (
site_settings): JSON-LD, contact info, default SEO
Secrets & env
-
DIRECTUS_KEYrotated to a fresh UUID -
DIRECTUS_SECRETrotated to a fresh UUID -
DIRECTUS_ADMIN_PASSWORDset to a strong value -
POSTGRES_PASSWORDset to a strong value -
REVALIDATE_SECRETset to a long random string -
NEXT_PUBLIC_SITE_URLset to the production domain (used in CSP, canonical, hreflang) -
NEXT_PUBLIC_DIRECTUS_URLset to the production Directus URL (HTTPS) -
REVALIDATE_URLset to the production frontend URL
Hardening
- Rate limiter migrated from in-memory to Redis-backed if running multiple frontend replicas
- Sentry (or equivalent error tracking) wired
- Security headers verified in
frontend/src/middleware.ts(CSP, HSTS, X-Frame-Options) - Backup procedure tested (see below)
Tests
-
npm run buildsucceeds infrontend/with production env vars -
docker compose up --buildboots the full stack cleanly from scratch - Bootstrap script applied without errors (
directus-initexits 0)
Required environment variables
| Var | Used by | Server-only? |
|---|---|---|
DIRECTUS_KEY | Directus | Yes |
DIRECTUS_SECRET | Directus | Yes |
DIRECTUS_ADMIN_EMAIL | Directus init | Yes |
DIRECTUS_ADMIN_PASSWORD | Directus init | Yes |
POSTGRES_USER | Postgres | Yes |
POSTGRES_PASSWORD | Postgres | Yes |
POSTGRES_DB | Postgres | Yes |
NEXT_PUBLIC_DIRECTUS_URL | Frontend GraphQL client | No |
NEXT_PUBLIC_SITE_URL | Canonical URLs, hreflang, CSP | No |
NEXT_PUBLIC_DEFAULT_LOCALE | next-intl default | No |
REVALIDATE_URL | Directus Flow → Next.js cache invalidation | Yes |
REVALIDATE_SECRET | Auth between Directus and /api/revalidate | Yes |
NEXT_PUBLIC_GA_ID | Google Analytics 4 (optional) | No |
NEXT_PUBLIC_GTM_ID | Google Tag Manager (optional) | No |
NEXT_PUBLIC_BOOKING_URL | Calendly / Cal.com floating CTA (optional) | No |
NEXT_PUBLIC_CHAT_PROVIDER | Live chat widget provider (optional) | No |
NEXT_PUBLIC_CHAT_WIDGET_ID | Live chat widget ID (optional) | No |
NEXT_PUBLIC_* vars are bundled into client JS: never put secrets there.
Path 1: Hybrid (recommended)
Vercel for the frontend (instant rollbacks, edge runtime, preview deploys per branch) + a managed host for the Directus stack.
Frontend on Vercel
- Push to GitHub.
- Vercel → Add New Project → Import the repo.
- Root directory: set to
frontend/(the project root contains both frontend and Directus folders). - Framework auto-detected as Next.js. Leave defaults.
- Add
NEXT_PUBLIC_*env vars andREVALIDATE_SECRETunder Project Settings → Environment Variables. - Click Deploy.
- Set up a custom domain.
Directus stack on a managed host
Pick one:
- Railway: push the
directus/folder; it auto-detects the Dockerfile. Add Postgres + Redis plugins. Set Directus env vars. - Fly.io:
fly launchfromdirectus/, attach a Fly Postgres app and Upstash Redis. - Render: create a Web Service from
directus/Dockerfile, attach a Render Postgres + Render Redis. - DigitalOcean App Platform: similar pattern.
Set the directus-init step as a release job (or run it manually once) so the schema is applied to the production DB.
Wire frontend → Directus
In Vercel env vars:
NEXT_PUBLIC_DIRECTUS_URL= the production Directus URL (HTTPS)NEXT_PUBLIC_SITE_URL= the Vercel custom domainREVALIDATE_SECRET= same value set in Directus
In Directus admin:
- Open Settings → Flows → find the Revalidate flow created by bootstrap.
- Confirm its
URLoperation points at${REVALIDATE_URL}/api/revalidate. - Confirm the auth header uses
REVALIDATE_SECRET.
Save. Edit a content row in Directus → confirm the frontend cache invalidates within ~1 second.
Path 2: All-Docker (single VPS)
For a single VPS (Hetzner, DigitalOcean Droplet, etc.) running everything via docker-compose.yml.
Server prep
- Ubuntu 22.04+ or similar.
- Install Docker + Docker Compose plugin.
- Install Caddy (auto HTTPS).
Production compose
The shipped docker-compose.yml is dev-tuned. For production:
- Change
databaseimage port binding to not expose 5432 publicly:database: # remove `ports:` or bind only to localhost # ports: ["127.0.0.1:5432:5432"] - Set strong values for every password / key in
.env. - Set
REVALIDATE_URLto the public frontend URL (e.g.,https://yourdomain.com), nothost.docker.internal. - Mount Postgres data volume on a fast disk; back it up regularly (see below).
Build the frontend image
The shipped repo runs the frontend in dev mode. For production add a Dockerfile under frontend/:
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev=false
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_DIRECTUS_URL
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_DEFAULT_LOCALE
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Add output: "standalone" to frontend/next.config.mjs.
Add the frontend service to docker-compose.yml:
frontend:
build:
context: ./frontend
args:
NEXT_PUBLIC_DIRECTUS_URL: https://yourdomain.com/cms
NEXT_PUBLIC_SITE_URL: https://yourdomain.com
NEXT_PUBLIC_DEFAULT_LOCALE: en
environment:
REVALIDATE_SECRET: ${REVALIDATE_SECRET}
depends_on:
directus:
condition: service_healthy
ports:
- "3000:3000"
restart: unless-stopped
Caddy config (/etc/caddy/Caddyfile)
yourdomain.com {
reverse_proxy localhost:3000
}
yourdomain.com/cms* {
reverse_proxy localhost:8055
}
Caddy auto-provisions Let's Encrypt certs.
Boot:
docker compose up -d --build
Path 3: Self-host with a managed Postgres
Same as Path 2 but use a managed Postgres (Supabase, Neon, RDS, Aiven). Drop the database service from compose; set DB_* env vars on the directus service to point at the managed instance.
This trades cost for backup reliability and PITR.
Backup strategy
| Data | Where | Recovery |
|---|---|---|
| Postgres (Directus content) | db_data volume | pg_dump cron to S3 / R2 / B2 |
| Uploaded files (Directus media) | directus_uploads volume | Volume snapshot or rclone to object storage |
| Code + envs | Git + secret manager | Re-deploy from prior commit |
Daily Postgres dump
Add to host crontab:
0 3 * * * docker exec saasforge-database pg_dump -U directus directus | gzip > /backup/directus-$(date +\%F).sql.gz && rclone copy /backup/ s3:my-bucket/directus/
Test restore quarterly: a backup you've never restored is not a backup.
Uploads backup
# Sync uploads to S3 daily
0 4 * * * rclone sync /var/lib/docker/volumes/saasforge-agency_directus_uploads/_data/ s3:my-bucket/uploads/
For managed hosts (Railway / Fly), use the platform's volume snapshot feature.
Monitoring
| Concern | Tool | Free tier? |
|---|---|---|
| Frontend errors | Sentry | Yes |
| API/server logs | Vercel logs / Logtail / Axiom | Yes |
| Web Vitals | Vercel Analytics | Yes (Pro) |
| Uptime checks | Better Stack / UptimeRobot | Yes |
| Directus health | /server/ping endpoint + Better Stack | Yes |
| Postgres queries | pg_stat_statements + the host's monitoring | Yes |
For Sentry on the frontend: install @sentry/nextjs, run npx @sentry/wizard@latest -i nextjs, set SENTRY_DSN and SENTRY_AUTH_TOKEN.
For Directus errors: enable Settings → Flows → create a flow that posts to your incident tool on directus.error events.
Rate limiting
The contact form ships with an in-memory rate limiter (frontend/src/lib/rate-limit.ts, default: 5 submissions / 10 min per IP). This breaks across multi-replica deployments: every replica has its own bucket.
For production with > 1 replica, swap to Redis-backed:
- Add an Upstash Redis or use the existing Redis service.
- Replace the in-memory
Mapinrate-limit.tswith a RedisINCR+EXPIREpattern (or use@upstash/ratelimit). - Plumb the Redis URL via env var.
Security headers
frontend/src/middleware.ts already sets:
Strict-Transport-Security(HSTS)X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy- A baseline CSP
For a stricter CSP (recommended for production), allowlist your specific Directus URL, GA/GTM domains, and any third-party scripts (chat widget, booking widget). Run a CSP report-only run first to catch what would break.
Post-deploy verification
Immediately after deploy:
- Hit
/en,/fr,/es: landing pages render in each locale, no console errors - Toggle dark mode on each
- Hit
/en/blog/[some-post-slug]: blog post renders with translations - Open the Directus admin → edit a content row → save → refresh frontend → confirm change appears within seconds
- Submit the contact form with a real entry → verify it appears in
form_submissionscollection - Check the chat widget loads (if enabled via
NEXT_PUBLIC_CHAT_PROVIDER) - Check booking CTA opens (if
NEXT_PUBLIC_BOOKING_URLset) - Run PageSpeed Insights against the production URL: verify good scores
- Run a hreflang validator against
/ento confirm cross-locale links are correct - Check JSON-LD with Google's Rich Results Test
If any step fails, check Vercel logs + Directus logs + the Directus Flows activity log in that order.
Custom domain
- Vercel: Project → Settings → Domains → Add → set DNS as instructed.
- Update
NEXT_PUBLIC_SITE_URL→ redeploy. - If Directus is on a subpath (e.g.,
/cms), update Caddy/Nginx routing accordingly. - Confirm hreflang links in
<head>use the new domain across all 3 locales.
Rollback
- Vercel frontend: previous deployment → Promote to Production (instant).
- Directus / Postgres: restore from the latest dump or volume snapshot. Schema rollbacks need manual
bootstrap.mjsadjustments: write reverse migrations if a release adds new collections/fields.
Database changes are not rolled back automatically. Test schema migrations on a staging clone first.
Related guides
- Hosting & Deployment: the original quick-start
- Architecture: how frontend + Directus + Postgres communicate
- Working with Directus: schema changes, GraphQL, permissions
- Content Editing: operating the CMS in production