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.ts fallbacks 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_KEY rotated to a fresh UUID
  • DIRECTUS_SECRET rotated to a fresh UUID
  • DIRECTUS_ADMIN_PASSWORD set to a strong value
  • POSTGRES_PASSWORD set to a strong value
  • REVALIDATE_SECRET set to a long random string
  • NEXT_PUBLIC_SITE_URL set to the production domain (used in CSP, canonical, hreflang)
  • NEXT_PUBLIC_DIRECTUS_URL set to the production Directus URL (HTTPS)
  • REVALIDATE_URL set 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 build succeeds in frontend/ with production env vars
  • docker compose up --build boots the full stack cleanly from scratch
  • Bootstrap script applied without errors (directus-init exits 0)

Required environment variables

VarUsed byServer-only?
DIRECTUS_KEYDirectusYes
DIRECTUS_SECRETDirectusYes
DIRECTUS_ADMIN_EMAILDirectus initYes
DIRECTUS_ADMIN_PASSWORDDirectus initYes
POSTGRES_USERPostgresYes
POSTGRES_PASSWORDPostgresYes
POSTGRES_DBPostgresYes
NEXT_PUBLIC_DIRECTUS_URLFrontend GraphQL clientNo
NEXT_PUBLIC_SITE_URLCanonical URLs, hreflang, CSPNo
NEXT_PUBLIC_DEFAULT_LOCALEnext-intl defaultNo
REVALIDATE_URLDirectus Flow → Next.js cache invalidationYes
REVALIDATE_SECRETAuth between Directus and /api/revalidateYes
NEXT_PUBLIC_GA_IDGoogle Analytics 4 (optional)No
NEXT_PUBLIC_GTM_IDGoogle Tag Manager (optional)No
NEXT_PUBLIC_BOOKING_URLCalendly / Cal.com floating CTA (optional)No
NEXT_PUBLIC_CHAT_PROVIDERLive chat widget provider (optional)No
NEXT_PUBLIC_CHAT_WIDGET_IDLive 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

  1. Push to GitHub.
  2. Vercel → Add New Project → Import the repo.
  3. Root directory: set to frontend/ (the project root contains both frontend and Directus folders).
  4. Framework auto-detected as Next.js. Leave defaults.
  5. Add NEXT_PUBLIC_* env vars and REVALIDATE_SECRET under Project Settings → Environment Variables.
  6. Click Deploy.
  7. 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 launch from directus/, 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 domain
  • REVALIDATE_SECRET = same value set in Directus

In Directus admin:

  • Open SettingsFlows → find the Revalidate flow created by bootstrap.
  • Confirm its URL operation 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:

  1. Change database image port binding to not expose 5432 publicly:
    database:
      # remove `ports:` or bind only to localhost
      # ports: ["127.0.0.1:5432:5432"]
    
  2. Set strong values for every password / key in .env.
  3. Set REVALIDATE_URL to the public frontend URL (e.g., https://yourdomain.com), not host.docker.internal.
  4. 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

DataWhereRecovery
Postgres (Directus content)db_data volumepg_dump cron to S3 / R2 / B2
Uploaded files (Directus media)directus_uploads volumeVolume snapshot or rclone to object storage
Code + envsGit + secret managerRe-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

ConcernToolFree tier?
Frontend errorsSentryYes
API/server logsVercel logs / Logtail / AxiomYes
Web VitalsVercel AnalyticsYes (Pro)
Uptime checksBetter Stack / UptimeRobotYes
Directus health/server/ping endpoint + Better StackYes
Postgres queriespg_stat_statements + the host's monitoringYes

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 SettingsFlows → 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:

  1. Add an Upstash Redis or use the existing Redis service.
  2. Replace the in-memory Map in rate-limit.ts with a Redis INCR + EXPIRE pattern (or use @upstash/ratelimit).
  3. Plumb the Redis URL via env var.

Security headers

frontend/src/middleware.ts already sets:

  • Strict-Transport-Security (HSTS)
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-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:

  1. Hit /en, /fr, /es: landing pages render in each locale, no console errors
  2. Toggle dark mode on each
  3. Hit /en/blog/[some-post-slug]: blog post renders with translations
  4. Open the Directus admin → edit a content row → save → refresh frontend → confirm change appears within seconds
  5. Submit the contact form with a real entry → verify it appears in form_submissions collection
  6. Check the chat widget loads (if enabled via NEXT_PUBLIC_CHAT_PROVIDER)
  7. Check booking CTA opens (if NEXT_PUBLIC_BOOKING_URL set)
  8. Run PageSpeed Insights against the production URL: verify good scores
  9. Run a hreflang validator against /en to confirm cross-locale links are correct
  10. 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

  1. Vercel: Project → Settings → Domains → Add → set DNS as instructed.
  2. Update NEXT_PUBLIC_SITE_URL → redeploy.
  3. If Directus is on a subpath (e.g., /cms), update Caddy/Nginx routing accordingly.
  4. 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.mjs adjustments: 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