Laravel Forge Deployment
Production-oriented guide for hosting EloSync on Laravel Forge: four sites (API, SPA, Docs, Marketing), environment variables, deploy scripts, daemons, scheduler, Reverb, and email.
Use this with the Production Runbook (launch blockers / smoke) and Notification System (Redis, Reverb TLS, Web Push). Local machines stay on Installation.
Recommended topology
Create four Forge sites (same server or separate servers). Do not mix PHP API, SPA static files, docs, and marketing on one web root.
| Site | Domain example | Git repo | Branch | Web directory | Node on server |
|---|---|---|---|---|---|
| API | api.example.com | DiligentCreators/EloSync-Backend | main | /public | Not required |
| SPA | app.example.com | DiligentCreators/EloSync-Frontend | build-artifacts | / (site root) | Not required |
| Docs | docs.example.com | DiligentCreators/EloSync-Docs | build-artifacts | / (site root) | Not required |
| Marketing | elosync.com / www.example.com | DiligentCreators/EloSync-Website | build-artifacts | / (site root) | Not required |
Rules
- SPA, Docs, and Marketing: CI builds on merge to
main; Forge deploys compiledbuild-artifactsonly. Never runnpm ci/vite/ VitePress /next buildon the Forge server for those sites. - API: Forge runs Composer +
artisanon each deploy. Prefer zero-downtime / quick deploy with shared.envand storage. - One SPA artifact serves many clients: each Forge SPA site owns its own
.envand generated/config.js. - Marketing is a static Next.js export (
out/) — no runtime Node; Forge writes/config.jsfor API URL and marketing pixels.
Server prerequisites (Forge)
On the API server (or shared box):
| Service | Forge action |
|---|---|
| PHP 8.3+ (8.4 preferred) | Site → PHP version |
| MySQL 8+ | Database → create DB + user |
| Redis | Server → Redis (install / enable) |
| Nginx + TLS | Site → SSL (Let's Encrypt) |
| Composer | Bundled with Forge PHP sites |
| Supervisor | Used by Daemons (queue, Reverb) |
| Scheduler | Site → Scheduler (or server cron) |
Enable Redis before setting CACHE_STORE=redis / QUEUE_CONNECTION=redis. Production must not use database/file cache with tenancy.
1. API site (EloSync-Backend)
1.1 Site settings
| Setting | Value |
|---|---|
| Repository | DiligentCreators/EloSync-Backend |
| Branch | main (or your release branch) |
| Project type | Laravel / PHP |
| Web directory | public |
| PHP version | 8.3+ / 8.4 |
| Composer | Install during deploy (--no-dev) |
1.2 Production .env (Forge → Environment)
Paste from backend .env.example (production-shaped template), then replace empty secrets. Shape:
APP_NAME="EloSync"
APP_ENV=production
APP_DEBUG=false
APP_KEY=
APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
CACHE_STORE=redis
QUEUE_CONNECTION=redis
CACHE_PREFIX=elosync_production_
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_PORT=6379
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=elosync
REVERB_APP_KEY=elosync-reverb-key
REVERB_APP_SECRET=
REVERB_HOST=reverb.example.com
REVERB_PORT=443
REVERB_SCHEME=https
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
REVERB_ALLOWED_ORIGINS=https://app.example.com
FILESYSTEM_DISK=s3
FILESYSTEM_BRANDING_DISK=public
# Avatars always use public local storage (default). Keep storage/ shared across zero-downtime releases.
# FILESYSTEM_AVATAR_DISK=publicRedis is required whenever Reverb / production cache / queue are enabled. Do not leave CACHE_STORE=database with tenancy in production.
| Variable | Production note |
|---|---|
APP_DEBUG | Must be false (boot fails closed if true in production) |
FRONTEND_URL | Absolute SPA origin for reset/invite links |
CORS_ALLOWED_ORIGINS | Pin SPA origin(s); never * |
REVERB_HOST | Public WebSocket host (reverb.example.com); browsers use this via SPA VITE_REVERB_* |
REVERB_SERVER_* | Internal listener behind Nginx (Forge Reverb proxy) |
CACHE_STORE / QUEUE_CONNECTION | redis required with Reverb and tenancy |
| Prefer Central Settings → Mail; env is fallback only | |
| Seeders | Never db:seed in production |
FCM (required for closed-browser alerts): set API FCM_PROJECT_ID / FCM_CLIENT_EMAIL / FCM_PRIVATE_KEY (or FCM_CREDENTIALS) plus SPA VITE_FIREBASE_* in /config.js (including the Firebase Web Push certificate key). Queue daemon must consume emails,default — FCM rides the same notification jobs. See Notification System.
1.3 Deploy script (API)
Forge → Site → Deploy Script. Adjust paths only if your site path differs ($FORGE_SITE_PATH).
$CREATE_RELEASE()
cd $FORGE_RELEASE_DIRECTORY
$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# storage/ is shared across zero-downtime releases — branding + avatars live under storage/app/public
$FORGE_PHP artisan storage:link --force || true
$FORGE_PHP artisan migrate --force
$FORGE_PHP artisan optimize
$FORGE_PHP artisan reverb:restart || true
$FORGE_PHP artisan pulse:restart || true
$ACTIVATE_RELEASE()
$FORGE_PHP artisan horizon:terminateDo not add php artisan db:seed, migrate:fresh, or local:seed-demo.
After Horizon is enabled, remove Forge queue:work daemons and run a single php artisan horizon daemon instead. Do not run both — they compete for the same Redis queues.
After first Multi-Provider Email release on an older DB, run once (SSH or one-off):
php artisan email:migrate-tenant-mail-modes1.4 Scheduler
Forge → Site → Scheduler → enable (runs schedule:run every minute). Confirm jobs from the Production Runbook appear in routes/console.php / schedule definition.
1.5 Daemons (Horizon, Pulse, Reverb)
Forge → Server → Daemons (or site Daemons). Use the site path Forge shows (example: /home/forge/api.example.com).
Horizon (Redis queue workers — replaces all queue:work daemons)
| Field | Value |
|---|---|
| Command | php artisan horizon |
| User | forge |
| Directory | /home/forge/api.example.com/current or /home/forge/api.example.com (match your zero-downtime layout) |
| Processes | 1 (Horizon spawns its own worker children) |
Worker queues and process limits are defined in config/horizon.php:
supervisor-general—automations,whatsapp-inbound,whatsapp-outbound,webhooks,emails,lead-ingest,imports,defaultwithbalance => falsesomaxProcessesis a real cap (production 2 processes, 90s timeout). Do not use Horizonautobalancing withminProcesses: 1on this many queues — that spawns ≥1 worker per queue (~8+) and thrashs small VPS RAM.supervisor-email-sync—email-sync,help-desk-ingest(production max 1 process, 300s timeout — IMAP sync for personal Email and Help Desk shared mailboxes)
Include whatsapp-inbound and whatsapp-outbound when the WhatsApp Cloud module is enabled (WhatsApp Cloud deployment). On small servers (≈4 GB RAM), keep total Horizon children at ≤3 (2 general + 1 email-sync) before scaling up.
Delete legacy Forge daemons that run php artisan queue:work redis --queue=... once Horizon is live.
Laravel Pulse (pulse:check)
| Field | Value |
|---|---|
| Command | php artisan pulse:check |
| User | forge |
| Directory | Same as API release root |
| Processes | 1 |
Required for the Pulse Servers card. Does not replace Horizon.
Laravel Pulse ingest (pulse:work) — required when PULSE_INGEST_DRIVER=redis
| Field | Value |
|---|---|
| Command | php artisan pulse:work |
| User | forge |
| Directory | Same as API release root |
| Processes | 1 |
Production should set PULSE_INGEST_DRIVER=redis (and ideally PULSE_REDIS_CONNECTION on a Redis DB separate from Horizon queues) so HTTP/queue workers do not write Pulse rows on the request path. Without pulse:work, the dashboard stays empty under Redis ingest. Deploy scripts should call pulse:restart alongside horizon:terminate.
Nightwatch — keep your existing nightwatch:agent (or nightwatch:run) daemon if enabled.
Reverb
| Field | Value |
|---|---|
| Command | php artisan reverb:start --host=127.0.0.1 --port=8080 |
| User | forge |
| Directory | Same as API release root |
| Processes | 1 |
Enable Forge Laravel Reverb / WebSocket proxy for the API site when available so Nginx terminates TLS and upgrades /app and /apps. Confirm:
- Public
wss://api.example.com(or dedicatedws.host) matches SPAVITE_REVERB_* REVERB_ALLOWED_ORIGINSis exactly the SPA origin- App secret never appears in SPA env
Requires PHP ext-imap on the server for the Email module. Details: Email deployment.
Prefer Forge’s “directory = current release” pattern so deploys + horizon:terminate pick up new code. If Daemons point at a fixed path, ensure it is the active release symlink. Supervisor stopwaitsecs for Horizon must exceed the longest job timeout (300s for email-sync).
Supervisor examples and troubleshooting: Notification System.
1.6 First-time API bootstrap (once)
On a greenfield production database (SSH as forge):
cd /home/forge/api.example.com
php artisan key:generate # if APP_KEY empty — then store in Forge Environment
php artisan migrate --force
# Create the first central admin via a controlled process — do NOT seed demo users in production
php artisan storage:linkProvision the first central user through your approved ops process (manual insert / one-off artisan), then sign in and configure Settings → Mail, payment gateways, and registration policy. Change any temporary password immediately.
1.7 Health
GET https://api.example.com/up→ 200 (DB + Redis when configured)- Forge → Monitoring / uptime on
/up
2. SPA site (EloSync-Frontend)
2.1 Site settings
| Setting | Value |
|---|---|
| Repository | DiligentCreators/EloSync-Frontend |
| Branch | build-artifacts |
| Web directory | / (compiled assets live at branch root) |
| Node.js | Not required for deploy |
| Nginx | SPA fallback to index.html for client routes |
If deep links 404, add a try_files fallback to index.html (Forge “Static HTML” / custom Nginx). Do not point Web Directory at dist/ on main — that branch has no committed build.
2.2 Site .env (runtime only)
Forge Environment for the SPA site (not the API site):
VITE_API_URL=https://api.example.com
VITE_APP_NAME=EloSync
VITE_API_MODE=central
# VITE_CENTRAL_PATH_PREFIX=dc-s87s
VITE_REVERB_APP_KEY=<same-as-REVERB_APP_KEY>
VITE_REVERB_HOST=reverb.example.com
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
# Optional marketing pixels — see Marketing Pixels guide
# VITE_GTM_ID=GTM-XXXXXXX
# VITE_META_PIXEL_ID=123456789012345
# VITE_LINKEDIN_PARTNER_ID=1234567
# VITE_X_PIXEL_ID=o1234VITE_API_URL has no trailing /api. Reverb host/port/scheme must match backend REVERB_HOST / public WebSocket endpoint. Keep VITE_CENTRAL_PATH_PREFIX in sync with the API site’s CENTRAL_PATH_PREFIX when customizing the Central login path. Optional marketing pixel keys: Marketing pixels.
2.3 Deploy script (SPA)
$CREATE_RELEASE()
cd $FORGE_RELEASE_DIRECTORY
if [ -f ../../.env ]; then
set -a
source ../../.env
set +a
fi
echo "window.env = {" > "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_API_URL: \"$VITE_API_URL\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_APP_NAME: \"${VITE_APP_NAME:-EloSync}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_API_MODE: \"${VITE_API_MODE:-central}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_CENTRAL_PATH_PREFIX: \"${VITE_CENTRAL_PATH_PREFIX:-central}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_REVERB_APP_KEY: \"$VITE_REVERB_APP_KEY\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_REVERB_HOST: \"$VITE_REVERB_HOST\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_REVERB_PORT: \"$VITE_REVERB_PORT\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_REVERB_SCHEME: \"$VITE_REVERB_SCHEME\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_GTM_ID: \"${VITE_GTM_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_META_PIXEL_ID: \"${VITE_META_PIXEL_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_LINKEDIN_PARTNER_ID: \"${VITE_LINKEDIN_PARTNER_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " VITE_X_PIXEL_ID: \"${VITE_X_PIXEL_ID:-}\"" >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo "};" >> "$FORGE_RELEASE_DIRECTORY/config.js"
$ACTIVATE_RELEASE()No npm install, no npm run build. CI already published assets; this script only activates the release and writes /config.js.
Trigger: Forge GitHub webhook on build-artifacts, or deploy after Actions finishes. Wrong API URL → fix SPA site .env and redeploy (regenerates config.js).
Details: Frontend Build Artifacts.
2.4 Cache headers (recommended)
| Path | Cache |
|---|---|
index.html, config.js | Short TTL or no-cache |
/assets/* (hashed) | Long-lived immutable |
3. Docs site (EloSync-Docs)
3.1 Site settings
| Setting | Value |
|---|---|
| Repository | DiligentCreators/EloSync-Docs |
| Branch | build-artifacts |
| Web directory | / |
| Deploy script | Activate only |
| Node.js | Not required |
3.2 Deploy script (Docs)
$CREATE_RELEASE()
cd $FORGE_RELEASE_DIRECTORY
$ACTIVATE_RELEASE()Clean URLs work on default Forge Nginx (try_files $uri $uri/ …) because CI writes directory index.html fallbacks. Optional:
location / {
try_files $uri $uri.html $uri/ /404.html;
}4. Marketing site (EloSync-Website)
4.1 Site settings
| Setting | Value |
|---|---|
| Repository | DiligentCreators/EloSync-Website |
| Branch | build-artifacts |
| Web directory | / |
| Deploy script | Activate only |
| Node.js | Not required |
4.2 Deploy script (Marketing)
$CREATE_RELEASE()
cd $FORGE_RELEASE_DIRECTORY
if [ -f ../../.env ]; then
set -a
source ../../.env
set +a
fi
echo "window.env = {" > "$FORGE_RELEASE_DIRECTORY/config.js"
echo " NEXT_PUBLIC_API_URL: \"${NEXT_PUBLIC_API_URL:-https://api.elosync.com}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " NEXT_PUBLIC_GTM_ID: \"${NEXT_PUBLIC_GTM_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " NEXT_PUBLIC_META_PIXEL_ID: \"${NEXT_PUBLIC_META_PIXEL_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " NEXT_PUBLIC_LINKEDIN_PARTNER_ID: \"${NEXT_PUBLIC_LINKEDIN_PARTNER_ID:-}\"," >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo " NEXT_PUBLIC_X_PIXEL_ID: \"${NEXT_PUBLIC_X_PIXEL_ID:-}\"" >> "$FORGE_RELEASE_DIRECTORY/config.js"
echo "};" >> "$FORGE_RELEASE_DIRECTORY/config.js"
$ACTIVATE_RELEASE()CI (Website production build) runs next build with output: "export" and publishes the out/ directory to build-artifacts. Forge activates the release and writes /config.js from the marketing site .env — never run npm / next on the server.
Optional Nginx 404 fallback:
location / {
try_files $uri $uri/ /404.html;
}Details: EloSync-Website README and repo docs/ci-cd/website-build-artifacts.md.
4.3 Environment (runtime)
Marketing site reads NEXT_PUBLIC_* keys from Forge /config.js (window.env) at runtime — same model as the SPA. Local dev uses .env.local. Guide: Marketing pixels.
5. Email on Forge
- Deploy API with queue daemon running (
emails,default). - Sign in to Central → Settings → Mail → SMTP / Postmark / Mailgun (not Log).
- Set From identity; configure provider webhooks to:
https://api.example.com/webhooks/email/{provider}- Tenant custom:
…/webhooks/email/{provider}/{tenant}
- Send test; confirm Email logs.
- Ensure
FRONTEND_URLis the production SPA so reset links work.
Env MAIL_* is only a fallback when Settings secrets are empty. Restart queue after credential changes (queue:restart is already in the API deploy script).
See Multi-Provider Email and Authentication ops.
6. Cross-site checklist (before go-live)
Wire-up
- [ ] API
APP_URL= public API HTTPS URL - [ ] API
FRONTEND_URL+CORS_ALLOWED_ORIGINS+REVERB_ALLOWED_ORIGINS= SPA origin - [ ] SPA
VITE_API_URL= API origin - [ ] SPA
VITE_REVERB_*matches public Reverb (TLS, host, port 443, key) - [ ] Stripe / Creem webhook URLs hit the API host; secrets non-empty for active gateways
- [ ]
BRANDED_SERVER_IPV4(and optional CNAME) set if using custom domains
Processes
- [ ] API scheduler enabled (includes
horizon:snapshotevery five minutes when Horizon is installed) - [ ] Horizon daemon running (
php artisan horizon) — replaces allqueue:workdaemons - [ ] Pulse daemon running (
php artisan pulse:check) when Pulse Servers card is used - [ ] When
PULSE_INGEST_DRIVER=redis:php artisan pulse:workdaemon running (and deploy callspulse:restart) - [ ] Reverb daemon + Nginx WebSocket proxy
- [ ] Redis up;
CACHE_STORE/QUEUE_CONNECTION=redis - [ ]
FCM_PROJECT_ID/FCM_CLIENT_EMAIL/FCM_PRIVATE_KEY(orFCM_CREDENTIALS) set on the API - [ ] SPA
VITE_FIREBASE_*complete in/config.js(including Web Push certificate key)
Deploy paths
- [ ] API deploys from
mainwith migrate + optimize +horizon:terminate+ reverb restart - [ ] SPA deploys from
build-artifactswithconfig.jsgeneration - [ ] Docs deploys from
build-artifactswith activate-only script - [ ] Marketing deploys from
build-artifactswith activate-only script - [ ] No production seeders /
migrate:fresh
Smoke
- [ ]
GET /upon API - [ ] Central + tenant login on SPA
- [ ] Forgot-password email leaves the queue
- [ ] Notification bell updates over Echo (or unread poll fallback)
- [ ] Docs homepage and a deep link load
- [ ] Marketing homepage loads (
elosync.comor configured domain)
Full launch blockers: Production Runbook.
7. Deploy order
When shipping a release that touches product repos:
- Backend — merge → Forge API deploy (migrations first)
- Frontend — merge → wait for Actions →
build-artifacts→ Forge SPA deploy - Docs — merge → wait for Actions →
build-artifacts→ Forge Docs deploy - Marketing — merge → wait for Actions →
build-artifacts→ Forge Marketing deploy (independent of API)
SPA may briefly talk to a newer API if you deploy backend first (preferred for additive APIs). Avoid deploying SPA features that require API routes before the API migrate finishes.
See Release Process.
8. Troubleshooting (Forge)
| Symptom | Check |
|---|---|
| SPA blank / wrong API | Site .env + redeploy so config.js regenerates; view-source /config.js |
| CORS errors | API FRONTEND_URL / CORS_ALLOWED_ORIGINS match SPA scheme+host |
| Echo never connects | Reverb daemon; Nginx /app /apps; SPA VITE_REVERB_*; REVERB_ALLOWED_ORIGINS |
| Mail stuck | Queue daemon; failed_jobs; Central Mail settings; not MAIL_MAILER=log |
| 500 after deploy | APP_DEBUG=false but check storage/logs; php artisan optimize / missing APP_KEY |
| Docs 404 on deep links | Branch is build-artifacts; Web Directory /; CI clean-url indexes present |
| Marketing 404 / blank | Branch is build-artifacts (not main); Web Directory /; Actions Website production build green |
| Stale SPA/docs/marketing | Confirm Forge site branch is build-artifacts, not main; redeploy after Actions |
Related
| Topic | Doc |
|---|---|
| Launch blockers / smoke | Production Runbook |
| Reverb / Redis / Web Push | Notification System |
SPA CI + config.js | Frontend Build Artifacts |
| Migrate-only upgrades | Upgrade Guide |
| Local install | Installation |
| Docs Forge notes | EloSync-Docs README |
| Marketing CI | EloSync-Website docs/ci-cd/website-build-artifacts.md |