Deployment Guide
This guide covers everything you need to move SaaS4Builders from your local Docker environment to a production server. It assumes you are deploying a standard server-based setup — the recommended path uses Laravel Forge or Ploi, but Docker and other platforms are also covered.
Production Checklist
Work through this checklist before going live. Every item matters.
Application Settings
| Variable | Dev Value | Production Value |
|---|---|---|
APP_ENV | local | production |
APP_DEBUG | true | false |
APP_KEY | auto-generated | Keep the generated key. Never share it. |
APP_URL | http://localhost:8000 | https://api.yourdomain.com |
FRONTEND_URL | http://localhost:3000 | https://app.yourdomain.com |
APP_TIMEZONE | UTC | UTC (recommended) |
APP_DEBUG=true in production exposes stack traces, environment variables, and database queries to users. Always set it to false.Authentication and CORS
| Variable | Production Value | Notes |
|---|---|---|
SANCTUM_STATEFUL_DOMAINS | app.yourdomain.com | Must exactly match your frontend domain (no protocol, include port if non-standard) |
SESSION_DOMAIN | .yourdomain.com | Prefixed with . to cover subdomains. Required for CSRF cookies to be sent correctly. |
CORS is configured in backend/config/cors.php:
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
Setting FRONTEND_URL correctly in your backend .env automatically configures CORS. Only your frontend domain will be allowed to make cross-origin requests.
SANCTUM_STATEFUL_DOMAINS does not exactly match your frontend domain, cookie-based authentication will silently fail. This is the most common production authentication issue.Database
Use a managed PostgreSQL service instead of a Docker container:
| Variable | Production Value |
|---|---|
DB_CONNECTION | pgsql |
DB_HOST | Your managed database hostname |
DB_PORT | 5432 (or your provider's port) |
DB_DATABASE | Your production database name |
DB_USERNAME | Your production database user |
DB_PASSWORD | A strong, unique password |
Run migrations on first deploy:
php artisan migrate --force
--seed in production. The base seeders (roles, currencies, catalog) should be run once manually after the first migration. Demo seeders are automatically skipped when APP_ENV=production.Cache, Sessions, and Queues
Use a managed Redis instance for all three:
| Variable | Production Value |
|---|---|
SESSION_DRIVER | redis |
CACHE_STORE | redis |
QUEUE_CONNECTION | redis |
REDIS_HOST | Your managed Redis hostname |
REDIS_PASSWORD | Your Redis password |
REDIS_PORT | 6379 (or your provider's port) |
Stripe Billing
Switch from test keys to live keys:
| Variable | Production Value |
|---|---|
STRIPE_KEY | pk_live_... (your Stripe publishable key) |
STRIPE_SECRET | sk_live_... (your Stripe secret key) |
STRIPE_WEBHOOK_SECRET | whsec_... (from your Stripe webhook endpoint) |
BILLING_MODE | stripe_managed |
BILLING_DEFAULT_CURRENCY | Your default currency code (e.g., USD) |
Register your webhook endpoint in the Stripe Dashboard:
- URL:
https://api.yourdomain.com/api/v1/stripe/webhook - Events to listen for: All
invoice.*,customer.subscription.*,checkout.session.*, andcharge.*events.
Replace Mailpit with a production mail provider:
MAIL_MAILER=mailgun
MAILGUN_DOMAIN=your-domain.com
MAILGUN_SECRET=your-mailgun-key
MAIL_MAILER=postmark
POSTMARK_TOKEN=your-postmark-token
MAIL_MAILER=ses
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
MAIL_MAILER=smtp
MAIL_HOST=smtp.your-provider.com
MAIL_PORT=587
MAIL_USERNAME=your-username
MAIL_PASSWORD=your-password
MAIL_ENCRYPTION=tls
Update the sender address:
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App Name"
OAuth Providers
If you use Google or GitHub OAuth, register new OAuth applications with your production domains:
| Variable | Production Value |
|---|---|
GOOGLE_REDIRECT_URI | https://api.yourdomain.com/api/v1/auth/oauth/google/callback |
GITHUB_REDIRECT_URI | https://api.yourdomain.com/api/v1/auth/oauth/github/callback |
Create new OAuth apps in the Google Cloud Console and GitHub Developer Settings with your production callback URLs.
Broadcasting / Reverb
If you use real-time features (WebSocket):
| Variable | Production Value |
|---|---|
REVERB_SCHEME | https |
REVERB_HOST_PUBLIC | ws.yourdomain.com (or your API domain) |
REVERB_PORT | 443 (if behind reverse proxy with SSL) |
Alternatively, replace Reverb with a managed service like Pusher or Ably.
Web Push Notifications
Generate production VAPID keys:
php artisan webpush:vapid
Set VAPID_SUBJECT to your production URL:
VAPID_SUBJECT=https://yourdomain.com
Logging
| Variable | Production Value |
|---|---|
LOG_CHANNEL | stack |
LOG_LEVEL | warning or error |
Avoid debug level in production — it generates excessive log volume and may expose sensitive data.
Cron and Scheduler
The Laravel scheduler requires a system cron job that runs every minute:
* * * * * cd /path-to-your-backend && php artisan schedule:run >> /dev/null 2>&1
On Forge or Ploi, the scheduler is configured through the UI — no manual cron setup needed.
Scheduled Tasks
The application schedules these tasks automatically:
| Command | Schedule | Purpose |
|---|---|---|
billing:sync-invoices | Daily at 02:00 | Sync invoices from Stripe |
billing:sync-charges | Daily at 03:00 | Sync charges from Stripe |
onboarding:cleanup-stale | Daily at 04:00 | Remove incomplete onboarding tenants |
These tasks run without overlapping (a second instance will not start if the previous one is still running).
Queue Worker
The queue worker processes background jobs (emails, webhooks, billing sync). It must run continuously in production.
php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Ensure QUEUE_CONNECTION=redis is set in your backend .env so the worker uses the Redis driver.
This command:
- Processes jobs from the configured queue connection (Redis)
- Waits 3 seconds between polling when idle
- Retries failed jobs up to 3 times
- Restarts automatically after 1 hour (prevents memory leaks)
Keeping the Worker Running
Use a process manager to ensure the worker restarts if it crashes:
[program:saas-queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path-to-your-backend/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/path-to-your-backend/storage/logs/worker.log
stopwaitsecs=3600
[Unit]
Description=SaaS Queue Worker
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/path-to-your-backend
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
On Forge or Ploi, queue workers are managed through the dashboard — add a worker with the command above.
After each deployment, restart the queue worker to pick up new code:
php artisan queue:restart
Deployment with Forge or Ploi
Laravel Forge and Ploi are the recommended deployment platforms. They handle server provisioning, SSL certificates, queue workers, and scheduled tasks.
Setup Steps
- Provision a server — Ubuntu 24.04 with PHP 8.3+, PostgreSQL 16, Redis 7, Nginx.
- Create a site — Point it to your domain. Set the web directory to
backend/public. - Configure environment — Add all production
.envvariables through the platform's UI. - Set up the database — Create a PostgreSQL database and user.
- Configure the queue worker — Add a worker with the command:
php artisan queue:work --sleep=3 --tries=3 --max-time=3600. - Enable the scheduler — Enable the cron-based scheduler in the platform settings.
- Set up SSL — Use the platform's Let's Encrypt integration.
Deploy Script
Configure your deploy script to run on each push:
cd /home/forge/api.yourdomain.com
# Pull latest code
git pull origin main
# Install dependencies (no dev packages)
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
# Run migrations
php artisan migrate --force
# Cache configuration for performance
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Restart the queue worker to pick up new code
php artisan queue:restart
Frontend Deployment
The Nuxt frontend is a separate deployment. Options:
- Static hosting — Run
pnpm buildand deploy the.output/directory to Vercel, Netlify, or Cloudflare Pages. - Node server — Run
pnpm buildand thennode .output/server/index.mjsbehind a reverse proxy. - Same server — Deploy both backend and frontend on the same server with separate Nginx configurations.
Build the frontend for production:
cd frontend
pnpm install --frozen-lockfile
pnpm build
Docker Production Deployment
docker-compose.yml is designed for development. For production Docker deployments, create a separate docker-compose.production.yml with production-appropriate settings.Key Differences from Development
| Concern | Development | Production |
|---|---|---|
| Frontend | Nuxt dev server with HMR | Built Nuxt app served via Node or Nginx |
| Mailpit | Captures all emails | Remove — use real mail provider |
| Database | Docker container | Managed PostgreSQL service |
| Redis | Docker container | Managed Redis service |
| Volumes | Bind mounts for live editing | Built images with code baked in |
| SSL | Not configured | Required — use Traefik, Caddy, or cloud LB |
| Resources | Unlimited | Set CPU/memory limits |
Recommendations
- Use managed database and Redis — Do not run PostgreSQL or Redis in Docker containers in production. Use your cloud provider's managed services (AWS RDS, DigitalOcean Managed Databases, etc.).
- SSL termination — Use a reverse proxy (Traefik, Caddy) or cloud load balancer for HTTPS.
- Container orchestration — For scaling, consider Docker Swarm or Kubernetes. For a single server, Docker Compose with production settings is sufficient.
Security Checklist
Before going live, verify every item:
-
APP_DEBUGisfalse -
APP_ENVisproduction -
APP_KEYis generated and stored securely (not committed to git) - HTTPS is enforced on all endpoints (API and frontend)
-
SANCTUM_STATEFUL_DOMAINSmatches your production frontend domain exactly -
FRONTEND_URLis set to your production frontend URL (controls CORS) - Database credentials are strong and unique (not the default
secret) - Stripe is using live keys (not test keys)
- Stripe webhook endpoint is registered with the correct production URL
- OAuth redirect URIs use production domains
-
LOG_LEVELiswarningorerror(notdebug) - Redis has a password set (if accessible from outside the private network)
- Queue worker is running and monitored (Supervisor or systemd)
- Scheduler cron job is configured
- VAPID keys are generated for production
-
MAIL_FROM_ADDRESSuses your actual domain
What's Next
- Environment Configuration — Detailed reference for all environment variables.
- Architecture Overview — Understand the codebase before building features.
- Docker Setup — Reference for the development Docker environment.