Deployment Guide
This document provides step-by-step instructions for deploying Tenanto to production environments.
Table of Contents
- Prerequisites
- Docker Production Deployment
- Environment Configuration
- Database Setup
- Application Deployment
- Web Server Configuration
- Queue Workers
- Scheduler
- SSL/TLS Configuration
- Monitoring
- Scaling
- Staging Environment
- Troubleshooting
Prerequisites
Server Requirements
| Component | Minimum | Recommended |
|---|---|---|
| PHP | 8.4+ | 8.4 |
| PostgreSQL | 15+ | 16 |
| Redis | 7+ | 7 |
| RAM | 2 GB | 4 GB+ |
| CPU | 2 cores | 4+ cores |
| Storage | 20 GB | 50 GB+ SSD |
Required PHP Extensions
bcmath
ctype
curl
dom
fileinfo
gd
intl
json
mbstring
openssl
pcntl
pdo
pdo_pgsql
redis
tokenizer
xml
zip
Install Dependencies
# Ubuntu/Debian
sudo apt update
sudo apt install -y \
php8.4-fpm php8.4-cli php8.4-pgsql php8.4-redis \
php8.4-bcmath php8.4-intl php8.4-gd php8.4-xml \
php8.4-curl php8.4-zip php8.4-mbstring \
nginx postgresql-16 redis-server supervisor
# Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
# Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
Docker Production Deployment
For containerized deployments, Tenanto includes production-ready Docker configurations:
Production Files
| File | Purpose |
|---|---|
docker-compose.prod.yml |
Production Docker Compose without exposed DB/Redis ports |
docker/php/php-prod.ini |
Hardened PHP configuration for production |
Quick Start (Docker)
# Copy production compose file
cp docker-compose.prod.yml docker-compose.override.yml
# Or run directly with production config
docker compose -f docker-compose.prod.yml up -d
# Run migrations
docker compose exec app php artisan migrate --force
# Optimize for production
docker compose exec app php artisan optimize
Security Features
The production Docker configuration includes:
- No exposed database ports - PostgreSQL and Redis only accessible within Docker network
- No Mailhog - Configure real SMTP provider (Mailgun, SES, Postmark)
- No Vite dev server - Production serves pre-built assets from
public/build/ - Strict Content Security Policy -
docker-compose.prod.ymlmountsdocker/nginx/sites.prod/instead ofdocker/nginx/sites/. The production CSP does not whitelisthttp://localhost:5273/ws://localhost:5273(the Vite dev server), so you never accidentally ship a dev-only allow-list to production. Both variants keephttps://fonts.bunny.netwhitelisted for the Inter web font. See docs/security.md - Content Security Policy for the full breakdown. - Hardened PHP settings (
php-prod.ini):display_errors = Offexpose_php = Offsession.cookie_secure = Ondisable_functionsfor dangerous functionsopen_basedirrestriction- OPcache optimized for production
Building Frontend Assets for Production
The production Docker image does not ship with Node.js. Build assets once before pushing your image (or as part of your CI/CD pipeline):
# Inside the dev environment, still with the vite service running:
docker compose exec app php -r "echo 'building assets...';"
docker compose run --rm --no-deps vite sh -c "npm ci && npm run build"
# public/build/ now contains the compiled manifest + hashed assets.
# Commit it (or bake it into your deploy artifact) before shipping.
Alternatively build outside Docker if you have Node 20+ installed on the build host:
npm ci
npm run build
public/build/manifest.json drives Laravel's @vite() directive in production
when no public/hot file is present.
Environment Variables
Ensure these are set in your .env for production Docker:
APP_ENV=production
APP_DEBUG=false
DB_HOST=db
REDIS_HOST=redis
REDIS_PASSWORD=your-secure-redis-password
Environment Configuration
1. Create Application Directory
sudo mkdir -p /var/www/tenanto
sudo chown -R www-data:www-data /var/www/tenanto
2. Clone Repository
cd /var/www/tenanto
# Clone from your repository
git clone https://your-git-host.com/your-org/tenanto.git .
3. Configure Environment
cp .env.example .env
Critical Production Settings:
# Application
APP_NAME="Tenanto"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
# Security
APP_KEY= # Generate with: php artisan key:generate
# Database
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=tenanto
DB_USERNAME=tenanto
DB_PASSWORD=your-secure-password
# Cache & Session
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
# Redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Mail (example: SMTP)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=your-username
MAIL_PASSWORD=your-password
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Tenanto"
# Stripe (Production Keys!)
STRIPE_KEY=pk_live_xxx
STRIPE_SECRET=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Plan Price IDs (Production)
STRIPE_PRICE_BASIC=price_xxx
STRIPE_PRICE_PRO=price_xxx
STRIPE_PRICE_ENTERPRISE=price_xxx
# Tenancy
TENANCY_MODE=subdomain
TENANCY_CENTRAL_DOMAINS=yourdomain.com,admin.yourdomain.com
# Logging
LOG_CHANNEL=daily
LOG_LEVEL=warning
4. Install Dependencies
# PHP dependencies (no dev packages)
composer install --optimize-autoloader --no-dev
# Node.js dependencies and build
npm ci
npm run build
# Generate key
php artisan key:generate
5. Set Permissions
sudo chown -R www-data:www-data /var/www/tenanto
sudo chmod -R 755 /var/www/tenanto
sudo chmod -R 775 /var/www/tenanto/storage
sudo chmod -R 775 /var/www/tenanto/bootstrap/cache
Database Setup
1. Create Database and User
sudo -u postgres psql
CREATE USER tenanto WITH PASSWORD 'your-secure-password';
CREATE DATABASE tenanto OWNER tenanto;
GRANT ALL PRIVILEGES ON DATABASE tenanto TO tenanto;
\q
2. Configure PostgreSQL SSL (Recommended)
For production environments, enable SSL connections to PostgreSQL:
# Generate or obtain SSL certificates for PostgreSQL
# Option 1: Self-signed (for internal use)
openssl req -new -x509 -days 365 -nodes -text \
-out /etc/postgresql/16/main/server.crt \
-keyout /etc/postgresql/16/main/server.key \
-subj "/CN=postgres-server"
sudo chown postgres:postgres /etc/postgresql/16/main/server.{crt,key}
sudo chmod 600 /etc/postgresql/16/main/server.key
Edit /etc/postgresql/16/main/postgresql.conf:
ssl = on
ssl_cert_file = '/etc/postgresql/16/main/server.crt'
ssl_key_file = '/etc/postgresql/16/main/server.key'
Edit /etc/postgresql/16/main/pg_hba.conf to require SSL:
# Require SSL for all connections
hostssl all all 0.0.0.0/0 scram-sha-256
Update Laravel .env for SSL connection:
DB_SSLMODE=require
# Or for certificate verification:
# DB_SSLMODE=verify-full
# DB_SSLROOTCERT=/path/to/ca-certificate.crt
Restart PostgreSQL:
sudo systemctl restart postgresql
3. Run Migrations
php artisan migrate --force
3. Seed Initial Data (Optional)
# Create system admin
php artisan db:seed --class=RoleAndPermissionSeeder
# Or seed demo data
php artisan db:seed
4. Create System Admin
php artisan tinker
$admin = \App\Models\User::create([
'name' => 'System Admin',
'email' => '[email protected]',
'password' => bcrypt('secure-password'),
'email_verified_at' => now(),
'tenant_id' => null, // System admin has no tenant
]);
$admin->assignRole('system-admin');
exit
Application Deployment
1. Optimize for Production
# Cache configuration
php artisan config:cache
# Cache routes
php artisan route:cache
# Cache views
php artisan view:cache
# Cache events
php artisan event:cache
# Optimize autoloader
composer dump-autoload --optimize
2. Storage Link
php artisan storage:link
3. Health Check
curl http://localhost/health
# Should return: {"status":"ok"}
Web Server Configuration
Nginx Configuration
Create /etc/nginx/sites-available/tenanto:
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name yourdomain.com *.yourdomain.com;
return 301 https://$host$request_uri;
}
# Main HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name yourdomain.com *.yourdomain.com;
root /var/www/tenanto/public;
index index.php;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Content Security Policy (adjust based on requirements)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com wss:; frame-src https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';" always;
# Permissions Policy
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(self), usb=()" always;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/rss+xml application/atom+xml image/svg+xml;
# Max upload size
client_max_body_size 100M;
# Logging
access_log /var/log/nginx/tenanto.access.log;
error_log /var/log/nginx/tenanto.error.log;
# Laravel
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 300;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
}
location ~ ^/(\.env|composer\.json|composer\.lock|package\.json) {
deny all;
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/tenanto /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Queue Workers (Laravel Horizon)
Tenanto uses Laravel Horizon for queue management, providing a dashboard, job metrics, and automatic worker scaling.
Supervisor Configuration
Create /etc/supervisor/conf.d/tenanto-horizon.conf:
[program:tenanto-horizon]
process_name=%(program_name)s
command=php /var/www/tenanto/artisan horizon
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/tenanto/storage/logs/horizon.log
stopwaitsecs=3600
Start Horizon:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start tenanto-horizon
Horizon Dashboard
Access the Horizon dashboard at /horizon (e.g., https://admin.yourdomain.com/horizon).
Access Control: Only system administrators (users without tenant with super-admin role) can access the dashboard in production.
Horizon Configuration
Key settings in config/horizon.php:
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10, // Adjust based on server resources
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
],
Queue Priorities
Tenanto uses prioritized queues:
| Queue | Priority | Used For |
|---|---|---|
high |
1st | Stripe webhooks, critical billing |
default |
2nd | Emails, notifications |
low |
3rd | Reports, cleanup jobs |
Deploying Horizon Updates
After deployment, restart Horizon to pick up code changes:
php artisan horizon:terminate
# Supervisor will auto-restart Horizon
Scheduler
Add to crontab (crontab -e as www-data):
* * * * * cd /var/www/tenanto && php artisan schedule:run >> /dev/null 2>&1
Or create a systemd timer:
/etc/systemd/system/tenanto-scheduler.service:
[Unit]
Description=Tenanto Scheduler
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/tenanto
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable tenanto-scheduler
sudo systemctl start tenanto-scheduler
SSL/TLS Configuration
Let's Encrypt with Certbot
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Get wildcard certificate
sudo certbot certonly --manual \
-d yourdomain.com \
-d '*.yourdomain.com' \
--preferred-challenges dns
# Auto-renewal
sudo certbot renew --dry-run
Certificate Auto-Renewal
Add to crontab:
0 0 1 * * certbot renew --quiet && systemctl reload nginx
Monitoring
Health Check Endpoints
/health- Basic health check (returns 200 if app is running)/ready- Readiness check (verifies database, cache, queue)
Sentry Integration
# .env
SENTRY_LARAVEL_DSN=https://[email protected]/xxx
composer require sentry/sentry-laravel
php artisan sentry:publish
Log Aggregation
Configure in .env:
LOG_CHANNEL=daily
LOG_LEVEL=warning
LOG_DEPRECATIONS_CHANNEL=null
For centralized logging, consider:
- Papertrail
- Loggly
- Elasticsearch + Kibana
Scaling
Horizontal Scaling
- Load Balancer - Use nginx, HAProxy, or cloud LB
- Session Storage - Redis (already configured)
- Cache - Redis cluster
- Database - PostgreSQL replication
- File Storage - S3 or similar object storage
Database Optimization
-- Useful indexes for tenant isolation
CREATE INDEX CONCURRENTLY idx_projects_tenant_archived
ON projects (tenant_id, is_archived) WHERE deleted_at IS NULL;
CREATE INDEX CONCURRENTLY idx_tasks_tenant_project_status
ON tasks (tenant_id, project_id, status) WHERE deleted_at IS NULL;
PHP-FPM Tuning
/etc/php/8.4/fpm/pool.d/www.conf:
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500
OPcache Settings
/etc/php/8.4/fpm/conf.d/10-opcache.ini:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.save_comments=1
Deployment Checklist
Before Deployment
- Run all tests locally
- Review environment configuration
- Backup current production database
- Notify users of maintenance window
During Deployment
# 1. Enable maintenance mode
php artisan down --secret="your-secret-token"
# 2. Pull latest code
git pull origin main
# 3. Install dependencies
composer install --optimize-autoloader --no-dev
npm ci && npm run build
# 4. Run migrations
php artisan migrate --force
# 5. Clear and rebuild caches
php artisan cache:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 6. Restart Horizon (queue workers)
php artisan horizon:terminate
# Supervisor will auto-restart Horizon
# 7. Disable maintenance mode
php artisan up
After Deployment
- Verify health check endpoints
- Test critical user flows
- Monitor error logs
- Check queue workers are processing
Rollback Procedure
If deployment fails:
# 1. Enable maintenance mode
php artisan down
# 2. Revert code
git checkout HEAD~1
# 3. Reinstall dependencies
composer install --optimize-autoloader --no-dev
# 4. Rollback migrations (if necessary)
php artisan migrate:rollback --step=1
# 5. Clear caches
php artisan cache:clear
php artisan config:cache
php artisan route:cache
# 6. Restart workers
sudo supervisorctl restart tenanto-worker:*
# 7. Disable maintenance mode
php artisan up
Backup Strategy
Database Backup
# Daily backup script with encryption
#!/bin/bash
set -e
BACKUP_DIR="/var/backups/tenanto"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="db_$TIMESTAMP.sql.gz"
ENCRYPTED_FILE="db_$TIMESTAMP.sql.gz.gpg"
# Create backup directory if not exists
mkdir -p "$BACKUP_DIR"
# Dump and compress database
pg_dump -U tenanto tenanto | gzip > "$BACKUP_DIR/$BACKUP_FILE"
# Encrypt backup for off-site storage (optional but recommended)
# First, import your GPG key: gpg --import backup-key.pub
GPG_RECIPIENT="${GPG_BACKUP_KEY:[email protected]}"
if gpg --list-keys "$GPG_RECIPIENT" > /dev/null 2>&1; then
gpg --encrypt --recipient "$GPG_RECIPIENT" \
--output "$BACKUP_DIR/$ENCRYPTED_FILE" \
"$BACKUP_DIR/$BACKUP_FILE"
# Remove unencrypted backup after successful encryption
rm "$BACKUP_DIR/$BACKUP_FILE"
echo "Encrypted backup created: $ENCRYPTED_FILE"
else
echo "Warning: GPG key not found. Backup stored unencrypted."
fi
# Keep only last 7 days
find $BACKUP_DIR -name "db_*.sql.gz*" -mtime +7 -delete
# Optionally upload to S3 or remote storage
# aws s3 cp "$BACKUP_DIR/$ENCRYPTED_FILE" s3://your-backup-bucket/tenanto/
Add to crontab:
0 3 * * * /var/www/tenanto/scripts/backup.sh
Decrypting Backups
# To restore from encrypted backup
gpg --decrypt db_20250101_030000.sql.gz.gpg | gunzip | psql -U tenanto tenanto
File Backup
# Backup uploads with encryption
#!/bin/bash
BACKUP_DIR="/var/backups/tenanto/files"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Sync files
rsync -avz /var/www/tenanto/storage/app/public/ "$BACKUP_DIR/"
# Create encrypted archive for off-site storage
tar -czf - -C /var/www/tenanto/storage/app public | \
gpg --encrypt --recipient "${GPG_BACKUP_KEY:[email protected]}" \
> "/var/backups/tenanto/files_$TIMESTAMP.tar.gz.gpg"
Staging Environment
A staging environment mirrors production and is essential for testing deployments before they go live.
Staging Server Setup
Set up a separate server (or container) that mirrors production:
# Create staging directory
sudo mkdir -p /var/www/tenanto-staging
sudo chown -R www-data:www-data /var/www/tenanto-staging
# Clone repository
cd /var/www/tenanto-staging
# Clone from your repository
git clone https://your-git-host.com/your-org/tenanto.git .
git checkout develop # Or specific release branch
Staging Environment Configuration
Create .env for staging:
# Application
APP_NAME="Tenanto Staging"
APP_ENV=staging
APP_DEBUG=true # Allowed in staging for debugging
APP_URL=https://staging.yourdomain.com
# Database (separate from production!)
DB_DATABASE=tenanto_staging
DB_USERNAME=tenanto_staging
DB_PASSWORD=staging-password
# Cache prefix to avoid conflicts
CACHE_PREFIX=staging_
REDIS_PREFIX=tenanto_staging_
# Stripe TEST keys (never use production keys!)
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_test_xxx
# Test price IDs
STRIPE_PRICE_BASIC=price_test_basic
STRIPE_PRICE_PRO=price_test_pro
STRIPE_PRICE_ENTERPRISE=price_test_enterprise
# Tenancy
TENANCY_MODE=subdomain
TENANCY_CENTRAL_DOMAINS=staging.yourdomain.com,admin.staging.yourdomain.com
# Email (use sandbox/testing service)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=mailtrap-username
MAIL_PASSWORD=mailtrap-password
# Logging (more verbose for staging)
LOG_CHANNEL=daily
LOG_LEVEL=debug
DNS Configuration for Staging
Configure DNS records:
# A records
staging.yourdomain.com -> staging-server-ip
*.staging.yourdomain.com -> staging-server-ip
# For tenant subdomains
tenant1.staging.yourdomain.com
tenant2.staging.yourdomain.com
Nginx Configuration for Staging
Create /etc/nginx/sites-available/tenanto-staging:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name staging.yourdomain.com *.staging.yourdomain.com;
root /var/www/tenanto-staging/public;
index index.php;
# SSL (can use separate staging certificate)
ssl_certificate /etc/letsencrypt/live/staging.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.yourdomain.com/privkey.pem;
# Basic auth to protect staging (optional but recommended)
auth_basic "Staging Environment";
auth_basic_user_file /etc/nginx/.htpasswd-staging;
# Disable basic auth for webhooks
location /stripe/webhook {
auth_basic off;
try_files $uri $uri/ /index.php?$query_string;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\. {
deny all;
}
}
Create staging password:
sudo htpasswd -c /etc/nginx/.htpasswd-staging staging-user
Staging Database Setup
sudo -u postgres psql
CREATE USER tenanto_staging WITH PASSWORD 'staging-password';
CREATE DATABASE tenanto_staging OWNER tenanto_staging;
GRANT ALL PRIVILEGES ON DATABASE tenanto_staging TO tenanto_staging;
\q
Continuous Deployment to Staging
The included .gitlab-ci.yml covers build, lint, test, and security scanning out of the box. For automated deployment to your own server, add a deploy job that SSHes to your staging host after the test stage passes.
Example GitLab CI Deploy Job
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache bash curl openssh-client
- eval "$(ssh-agent -s)"
- printf '%s' "${SSH_PRIVATE_KEY}" | base64 -d | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan "${SSH_HOST}" >> ~/.ssh/known_hosts
script:
- ssh "${SSH_USER}@${SSH_HOST}" "cd /var/www/tenanto && git pull && ./deploy.sh"
environment:
name: staging
url: https://staging.yourdomain.com
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $SSH_HOST'
Required GitLab CI/CD variables (Settings → CI/CD → Variables, marked Protected):
SSH_HOST- your staging server hostname or IPSSH_USER- deploy user on the server (create a dedicated user with limited sudo)SSH_PRIVATE_KEY- the deploy user's private key, base64-encoded to avoid GitLab multi-line variable issues
Manual Deployment Example
A simple on-server deployment script looks like this:
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/tenanto
git pull --ff-only origin main
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan migrate --force
php artisan optimize:clear && php artisan optimize
sudo systemctl restart php-fpm
sudo systemctl restart tenanto-queue
Adjust paths, service names, and container orchestration to match your own infrastructure.
Staging Horizon Configuration
Create /etc/supervisor/conf.d/tenanto-staging-horizon.conf:
[program:tenanto-staging-horizon]
process_name=%(program_name)s
command=php /var/www/tenanto-staging/artisan horizon
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/tenanto-staging/storage/logs/horizon.log
stopwaitsecs=3600
Staging Horizon dashboard: https://admin.staging.yourdomain.com/horizon
Testing in Staging
Pre-Production Checklist
Before promoting to production, verify in staging:
- All migrations run successfully
- User registration and login work
- Tenant creation works
- Subdomain routing works
- Stripe test payments process correctly
- Email delivery works (check Mailtrap)
- Queue jobs process correctly
- API endpoints respond correctly
- No errors in application logs
Automated Smoke Tests
Run smoke tests against staging:
# Health checks
curl -f https://staging.yourdomain.com/health
curl -f https://staging.yourdomain.com/ready
# API health
curl -f https://staging.yourdomain.com/api/v1/health
Data Seeding for Staging
Create staging-specific seeders:
# Seed staging with test data
php artisan db:seed --class=DemoDataSeeder
# Create test tenant
php artisan tenant:create "Test Company" test
# Create demo tenants
php artisan demo:create [email protected]
Promoting Staging to Production
Once staging is verified:
# 1. Tag the release
git tag -a v1.x.x -m "Release v1.x.x"
git push origin v1.x.x
# 2. Deploy to production
# (Follow the standard deployment checklist)
Staging Environment Isolation
Critical Security Notes:
- Never share databases - Staging must have its own database
- Never use production API keys - Always use Stripe test keys
- Protect staging access - Use basic auth or IP whitelisting
- Separate Redis instances - Use different Redis databases or prefixes
- Isolated file storage - Don't share storage with production
Staging Maintenance
# Reset staging database (start fresh)
php artisan migrate:fresh --seed
# Sync production data to staging (sanitized)
pg_dump -U tenanto tenanto | psql -U tenanto_staging tenanto_staging
# IMPORTANT: Run data sanitization script after sync!
# Clear staging data
php artisan cache:clear
php artisan queue:clear
Troubleshooting
Common Issues
500 Error
tail -f /var/www/tenanto/storage/logs/laravel.log
tail -f /var/log/nginx/tenanto.error.log
Permission Denied
sudo chown -R www-data:www-data /var/www/tenanto/storage
sudo chmod -R 775 /var/www/tenanto/storage
Queue/Horizon Not Processing
# Check Horizon status
sudo supervisorctl status tenanto-horizon
php artisan horizon:status
# Restart Horizon
php artisan horizon:terminate
sudo supervisorctl restart tenanto-horizon
# Check Horizon dashboard for failed jobs
# https://admin.yourdomain.com/horizon
Cache Issues
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear
Debug Mode (Temporary)
# Enable temporarily for debugging
php artisan down
# Edit .env: APP_DEBUG=true
php artisan config:clear
# Test, then revert
# Edit .env: APP_DEBUG=false
php artisan config:cache
php artisan up
Related Documentation
- Security Guide - Security configuration
- Performance Guide - Optimization strategies
- Backup & Restore - Backup strategies
- User Guide: Installation - Step-by-step install walkthrough
Support
For deployment assistance, contact the Tenanto development team.