Loading...

This is taking longer than expected.

Back to the help centre

Deploy your project

What the server needs, what a release runs, and the three settings people forget.

This guide is the release procedure: what the server needs before the first deploy, the commands every deploy runs, the production values of the environment, and the processes that have to stay running afterwards. You need it once for the first server and again every time a release behaves differently from your machine.

What the server needs

  • PHP 8.2 or newer with the extensions Laravel requires (ctype, curl, dom, fileinfo, filter, hash, mbstring, openssl, pcre, pdo, session, tokenizer, xml), the driver of your database (pdo_mysql, pdo_pgsql or pdo_sqlite), simplexml and json for the AWS SDK, and gd for the image thumbnails and pwa:generate-assets. bcmath or gmp is optional and speeds up web push signatures.
  • Composer, and Node with npm to build the assets. The build uses Vite 7, which wants a current Node LTS (20.19 or newer).
  • MySQL, PostgreSQL or SQLite. The queue, the cache and the sessions all default to database tables, so one database is enough to start.
  • A web server whose document root is public/, behind HTTPS with a real certificate. Web push, the installable PWA and the SameSite=None session cookie the PWA switches on all refuse to work on plain HTTP. bootstrap/app.php trusts every proxy (trustProxies(at: '*')), so a load balancer that terminates TLS is detected correctly.
  • Cron, to run the scheduler once a minute, and Supervisor or an equivalent to keep the queue worker alive. Both are set up below.
  • S3-compatible storage when FILESYSTEM_DISK stays at its default: the default disk in config/filesystems.php is s3, not local.

The release

Run this on every deploy, from the project root, in this order:

git reset --hard
git pull
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan storage:link
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart
Command What it does
git reset --hard && git pull Discards anything edited on the server and takes the new code. Anything you changed by hand on the server is gone, which is the point
composer install --no-dev --optimize-autoloader Installs the PHP dependencies without the development ones (debugbar, IDE helper, Dusk, PHPUnit, the coding standard). Runs package:discover and points core.hooksPath at scripts/git-hooks, which is harmless on a server
npm ci && npm run build Installs the exact JavaScript dependencies of package-lock.json and compiles resources/css and resources/js into public/build/. The compiled files are not committed
php artisan migrate --force Runs new migrations. --force skips the confirmation Laravel asks for in production
php artisan storage:link Creates public/storage pointing at storage/app/public. Idempotent
php artisan optimize:clear Drops every cache: config, routes, views, events, compiled classes. Do this before caching again so nothing stale survives
php artisan config:cache Merges config/*.php and .env into one file. After this .env is not read at all: a variable changed without re-running this command is ignored
php artisan route:cache Caches the route table, closures included, so the route files are not parsed on every request
php artisan view:cache Precompiles the Blade templates
php artisan event:cache Caches the listener discovery. The communication logs and billing listeners live in app/Listeners and are discovered, not registered by hand
php artisan queue:restart Tells every running worker to finish its current job and exit, so Supervisor starts it again with the new code. Workers keep the old code in memory until this runs

php artisan optimize runs the four cache commands in one go if you prefer.

Two commands from the older procedure are worth a word. php artisan db:seed --force is safe to repeat, because every seeder uses firstOrCreate, but it creates the accounts listed in sudo and default_users in config/app.php, and the shipped default_users is [email protected] with a predictable password. Take that entry out before seeding a production database. php artisan lang:search and php artisan lang:sync rewrite lang/*.json through the AI provider in config/ai.php; run them on your machine and commit the result, because on the server the next git reset --hard throws their output away.

The DevZone (/help/the-devzone) has a "Run deploy" button for small releases: it pulls the current branch from the HTTPS remote with the credentials in GIT_USER and GIT_PASSWORD, then runs the commands you type in the box, joined with &&. Each of the two processes is killed after ten seconds, so it fits a pull plus config:cache, not composer install or npm run build. A release that touches dependencies or assets goes through the script above.

php artisan help:stamp --check --pushed runs in the pre-push hook and fails the push when a help document it sends has a translation missing or stale; documents the push does not touch, and anything not in a commit, do not count. It is a repository check, not a deploy step; run php artisan help:stamp --check in CI if your pipeline pushes without the hook.

The production environment

.env.example is written for a development machine. These are the values that change:

Variable Production value Why
APP_ENV production Turns off the local-only behaviour: the debugbar and the query detector, and reporting every exception without the rate limit
APP_DEBUG false Hides stack traces from visitors. Also the switch that makes the session cookie secure when APP_PWA is on
APP_URL https://your-project.com Used in every generated link, the manifest scope, the VAPID subject and the default mail EHLO domain
APP_KEY the one generated once Encrypts sessions and everything encrypted. Generate it once with php artisan key:generate --show, store it, never regenerate it on a live site
QUEUE_CONNECTION database (default) or redis Anything but sync. With sync every job runs inside the request, and an announcement to a thousand users blocks the admin who published it
CACHE_STORE database (default) or redis Never array in production: queue:restart and the exception rate limiter both need a cache the worker and the web process share
SESSION_DRIVER database What .env.example sets; the sessions table exists. The config default is file
FILESYSTEM_DISK s3 with the AWS_* variables, or public The config default is s3. Use public to keep uploads on the server's disk
LOG_CHANNEL daily (default) One file per day in storage/logs/. Set SLACK_BOT_TOKEN and SLACK_LOG_CHANNEL and exceptions are also posted to Slack, rate-limited so a repeating error posts at the 1st, 10th, 25th, 50th, 100th, 300th, 500th and 1000th occurrence and every thousand after
MAIL_MAILER failover or one transport failover tries mailgun then ses, so both need their keys. See /help/email-and-sms-delivery
MAIL_FROM_ADDRESS an address on your domain The sender of every email; the default in .env.example is Weblabor's
APP_PWA true when you want the installable app and push Off, the manifest and the service worker are not injected. See /help/web-push-and-the-pwa
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY the pair from php artisan webpush:vapid --show Required for web push. The same pair everywhere the same users subscribe
FEATURE_* whichever features you sell Every flag defaults to off. See /help/configure-your-project
STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET live keys Only when plans or add-ons are on. The project's webhook handler answers at POST /api/stripe/webhook; register that URL in Stripe. Without a real STRIPE_SECRET (empty, or still your-stripe-secret) the deploy and the seeders still finish: plans, add-ons and meters are saved without a Stripe product, which is created the first time the record is priced or edited once a real key is set. See /help/turn-on-plans-and-billing

Remove the placeholders .env.example ships with (your-aws-access-key-id, your-stripe-key, the Telnyx ids): a placeholder is not empty, and code that checks "is this configured?" will believe it is.

The queue worker

Announcements, plan migrations and every notification you mark ShouldQueue run in a worker. Start one with Supervisor so it survives crashes and reboots. Create /etc/supervisor/conf.d/your-project-worker.conf:

[program:your-project-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/your-project/artisan queue:work --sleep=3 --tries=3 --max-time=3600
directory=/var/www/your-project
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/your-project/storage/logs/worker.log
stopwaitsecs=3600

Then:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start your-project-worker:*

--sleep=3 waits three seconds when the queue is empty, --tries=3 moves a job to failed_jobs after its third failure, and --max-time=3600 makes the worker exit after an hour so Supervisor restarts it with fresh memory. stopwaitsecs must be at least as long as your longest job. Raise numprocs when one worker falls behind. What is queued, how to watch it and how to retry failures is in /help/queues-and-scheduled-work.

The scheduler

Two tasks are scheduled out of the box: the daily login statistics at midnight and the exchange-rate snapshot at noon (UTC, the application timezone). They only run if cron calls the scheduler every minute:

* * * * * cd /var/www/your-project && php artisan schedule:run >> /dev/null 2>&1

Add it to the crontab of the same user that owns the files, so the log files it creates stay writable by the web server.

The three mistakes

A flag nobody set on the server. Every optional feature is an environment variable that defaults to off. A section that works on your machine and is missing in production is a FEATURE_* variable, or APP_PWA, that was set in your .env and never in the server's. The admin panel loses the section too, so it is not a permission problem.

Assets that were never built. public/build/ is not committed. If the page loads with no styles, or the browser console reports that /build/manifest.json is missing, npm run build did not run where the site is served, or it ran with a Node version Vite refused.

A stale configuration cache. Once config:cache has run, .env is a file nobody reads. A variable you changed and that the application ignores is a cache built before the change: run php artisan config:cache again. The same applies to routes after adding one, and to queue:restart after any deploy, because a worker that was not restarted runs last week's code.