Deploying Laravel on a VPS without interrupting requests takes more than copying new code into place. This guide connects Nginx, PHP-FPM and Redis queues managed by Horizon with release directories, shared data and a controlled switch. Zero downtime is a target to verify, not a property guaranteed by a symlink command.
The examples describe one existing application on a Linux VPS with GNU tools and systemd. Adapt users, PHP version, sockets, paths and service limits to your installation. Test preparation, failed health gates, worker shutdown and rollback in staging before using the sequence for production.
The Mental Model: How a Laravel App Lives on a VPS
Nginx serves public files and sends the front controller to PHP-FPM. Horizon manages Redis-backed Laravel queues through separate CLI processes. A deployment prepares code in a new release directory while current identifies the active release. Shared state and old processes need explicit treatment because they outlive a code-directory switch.
Release directories avoid overwriting PHP files while requests are running. They do not make database changes, cache formats or queued payloads atomic. Keep old and new code compatible during the transition, and budget CPU and RAM for overlapping processes and deployment work.
Nginx + PHP‑FPM: The Calm, Fast Front Door
Keep the document root at current/public and execute only Laravel’s front controller. Restrict access to dotfiles and prevent uploaded PHP from running. PHP-FPM, CLI PHP and extensions must match the application’s Composer requirements; the examples below use PHP 8.3, which meets the Laravel 13.x PHP baseline, not a requirement to upgrade an older application without testing.
Nginx Front-Controller Example
This HTTP example belongs behind a properly configured trusted TLS terminator, or must be adapted into a tested HTTPS server before public use. It is not a complete certificate setup. It passes only /index.php to FPM, denies other PHP paths and hidden files, and limits long-lived caching to an inspected public build-assets directory. Configure trusted proxies correctly if TLS terminates upstream.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/myapp/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/myapp-fpm.sock;
fastcgi_hide_header X-Powered-By;
}
# Only the exact front controller above executes PHP.
location ~* [.]php(?:/|$) { return 404; }
location ~ /[.](?!well-known(?:/|$)) { deny all; }
# Only genuine public assets with versioned URLs belong here.
location /build/assets/ {
try_files $uri =404;
expires 30d;
access_log off;
}
client_max_body_size 20m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
$realpath_root resolves the release path for PHP; test that requests consistently load that release. expires already generates cache freshness headers, so the static location does not add a second Cache-Control or suppress inherited security headers. Vite hashed URLs or correctly referenced Mix versions support long caching, but old asset URLs must remain available while cached HTML and open clients still reference them. Ordinary uploads are not automatically immutable or safe active content.
For origin TLS, see the Nginx TLS and Brotli guide. Check certificate renewal and the actual browser-to-edge and edge-to-origin paths separately. Current Let’s Encrypt certificates do not use OCSP stapling; support depends on the issuer.
PHP-FPM Pool and Resource Limits
Queue workers and Horizon are CLI processes, not members of PHP-FPM pools; they can still compete with web requests for CPU, RAM and database capacity. The following pool is illustrative. The Nginx worker is assumed to run as www-data and must access the socket. Before loading it, an administrator must provision the slowlog directory/file with permissions appropriate for the FPM master and configure rotation; do not assume the path exists. Use separate application users for stronger isolation where required.
[myapp]
user = www-data
group = www-data
listen = /run/php/myapp-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
; Illustrative limits: size from measured web and queue demand.
pm.max_children = 20
pm.process_idle_timeout = 10s
pm.max_requests = 1000
request_terminate_timeout = 60s
request_slowlog_timeout = 3s
slowlog = /var/log/php8.3-fpm/myapp-slow.log
; Configure and validate OPcache in the FPM SAPI configuration.
Choose ondemand or dynamic from observed load and latency requirements. Bound children using measured peak private process memory, queueing and downstream limits; PHP memory_limit is not the complete process footprint. A 60-second request limit can terminate valid work and must fit the application. The Laravel production optimization guide explores related settings. Check socket directory access and any ACL/SELinux/AppArmor policy too.
Use releases/current/shared to separate immutable code from persistent runtime data. Provision the base and releases directories for the deploy account, while giving the web/queue account read and directory-search access without write access to application code. The following layout is conceptual; release identifiers should be unique and recorded in a manifest.
/var/www/myapp
├── current -> /var/www/myapp/releases/20261104-010000-build1
├── releases
│ ├── 20261104-010000-build1
│ │ └── bootstrap/cache/views # release-local compiled views
│ └── 20261103-230000-build0
└── shared
├── storage
└── .env
Shared storage preserves uploads, logs and other intentionally persistent state. Keep bootstrap/cache local to each release. Because view:cache first clears the configured compiled-view directory, sharing the default storage/framework/views can affect live code. In the application’s config/view.php, set compiled to base_path(‘bootstrap/cache/views’) and create that directory before Composer scripts or Artisan run. Verify the actual config value in candidate health checks. Public uploads need public/storage linked only to storage/app/public; private files must stay outside that public link.
The following one-time administrator example initializes the real shared target for deploy and www-data on a filesystem with POSIX ACL support. It does not operate through a release’s storage symlink. Provision the parent /var/www/myapp and releases directories first; both accounts need directory-search access. Install shared/.env separately with deploy ownership and read access for the runtime account, no public access, and an existing production APP_KEY. Never generate a new key on every deployment.
# One-time setup after provisioning the application parent directories.
# Adapt account names; the shared storage path must be a real directory.
sudo install -d -o deploy -g www-data -m 2770 /var/www/myapp/shared/storage/app/public /var/www/myapp/shared/storage/app/private /var/www/myapp/shared/storage/framework/cache/data /var/www/myapp/shared/storage/framework/sessions /var/www/myapp/shared/storage/framework/views /var/www/myapp/shared/storage/logs
sudo setfacl -R -P -m u:deploy:rwX,u:www-data:rwX,m::rwX,o::--- /var/www/myapp/shared/storage
sudo find -P /var/www/myapp/shared/storage -type d -exec setfacl -m d:u::rwx,d:u:deploy:rwx,d:u:www-data:rwx,d:g::r-x,d:m::rwx,d:o::--- {} +
ACL masks limit effective permissions; inspect them and test newly created files from both accounts, not just existing directories. X adds directory search or preserves existing execute rights instead of making every data file executable. Application-created files can still request stricter modes, so test uploads, logs, sessions and cache writes. Avoid following arbitrary symlinks while changing ownership or permissions; shared data is never a cleanup target.
Prepare and Switch Release Directories
Prepare and validate a candidate while current still serves the old release. A temporary symlink renamed over current in the same directory can avoid a missing-path window. Atomic namespace replacement does not make requests, database changes or shared data a single transaction. A rollback target must remain compatible with current state.
An Explicit Deployment Sequence
This update-only scaffold assumes an existing current symlink, a configured Horizon service, prepared shared storage/.env and a trusted CI artifact with built assets plus composer.lock. Run it as deploy, with narrow sudo permission for the named services, not as root. GNU mv, flock, rsync and ACL utilities are prerequisites. The application must already use the release-local compiled-view path above. ops/check-release and ops/check-live are application-specific executable gates you must implement: the first targets the candidate’s real path through a private test vhost/FPM and checks boot, view path, required dependencies and a side-effect-free request; the second checks the active HTTP path and new worker release. The script stops if those gates are missing.
#!/usr/bin/env bash
set -euo pipefail
umask 027
APP_DIR=/var/www/myapp
RELEASES="$APP_DIR/releases"
SHARED="$APP_DIR/shared"
SOURCE_DIR=$(realpath -- "${1:?Pass the trusted CI artifact directory}")
[[ -L "$APP_DIR/current" && -r "$SHARED/.env" ]]
[[ -d "$SHARED/storage" && ! -L "$SHARED/storage" ]]
[[ -f "$SOURCE_DIR/artisan" && -f "$SOURCE_DIR/composer.lock" ]]
[[ -x "$APP_DIR/ops/check-release" && -x "$APP_DIR/ops/check-live" ]]
# All deploy, rollback and cleanup operations must share this local lock.
exec 9>"$APP_DIR/.deploy.lock"
flock -n 9
OLD_RELEASE=$(readlink -f -- "$APP_DIR/current")
[[ "$OLD_RELEASE" == "$RELEASES/"* && -d "$OLD_RELEASE" ]]
NEW_RELEASE=$(mktemp -d "$RELEASES/$(date -u +%Y%m%d-%H%M%S)-XXXXXX")
trap 'printf "%s\n" "Deployment failed; inspect current and service state before recovery." >&2' ERR
# Clean artifact contract: no local secrets, vendor or cached bootstrap state.
rsync -a --exclude=/.git/ --exclude=/node_modules/ --exclude=/vendor/ \
--exclude=/.env --exclude='/.env.*' --exclude=/storage/ \
--exclude=/bootstrap/cache/ --exclude=/public/storage \
"$SOURCE_DIR/" "$NEW_RELEASE/"
printf '%s\n' "$OLD_RELEASE" > "$NEW_RELEASE/.previous-release"
setfacl -R -P -m u:www-data:rX "$NEW_RELEASE"
install -d -m 2770 "$NEW_RELEASE/bootstrap/cache/views"
setfacl -R -P -m u:www-data:rwX,m::rwX "$NEW_RELEASE/bootstrap/cache"
find -P "$NEW_RELEASE/bootstrap/cache" -type d -exec \
setfacl -m d:u::rwx,d:u:deploy:rwx,d:u:www-data:rwx,d:g::r-x,d:m::rwx,d:o::--- {} +
ln -s -- "$SHARED/.env" "$NEW_RELEASE/.env"
ln -s -- "$SHARED/storage" "$NEW_RELEASE/storage"
cd -- "$NEW_RELEASE"
/usr/bin/php8.3 /usr/local/bin/composer install \
--no-dev --no-interaction --prefer-dist --optimize-autoloader
# Include newly installed vendor files in runtime read permissions.
setfacl -R -P -m u:www-data:rX "$NEW_RELEASE"
setfacl -R -P -m u:www-data:rwX,m::rwX "$NEW_RELEASE/bootstrap/cache"
/usr/bin/php8.3 artisan storage:link
/usr/bin/php8.3 artisan config:cache
/usr/bin/php8.3 artisan route:cache
/usr/bin/php8.3 artisan view:cache
# Set only after reviewing a migration plan compatible with the old release.
if [[ "${RUN_COMPATIBLE_MIGRATIONS:-0}" == 1 ]]; then
/usr/bin/php8.3 artisan migrate --force
fi
"$APP_DIR/ops/check-release" "$NEW_RELEASE"
# Explicit stop suppresses systemd's Restart=always while old jobs drain.
sudo systemctl stop horizon
[[ "$(sudo systemctl show horizon -p Result --value)" == success ]]
[[ "$(sudo systemctl show horizon -p SubState --value)" == dead ]]
# Both symlink names have the same parent filesystem; no unlink gap.
TEMP_LINK="$APP_DIR/.current-$(basename -- "$NEW_RELEASE")"
[[ ! -e "$TEMP_LINK" && ! -L "$TEMP_LINK" ]]
ln -s -- "$NEW_RELEASE" "$TEMP_LINK"
mv -Tf -- "$TEMP_LINK" "$APP_DIR/current"
# Adapt to the tested FPM lifecycle and actual service name.
sudo systemctl reload php8.3-fpm
sudo systemctl start horizon
"$APP_DIR/ops/check-live" "$NEW_RELEASE"
# Record current/previous releases; keep old code and assets until drained.
printf '%s\n' "$OLD_RELEASE" > "$APP_DIR/previous-release"
# No automatic rm: retention is a separate locked, path-validated operation.
GNU mv -T treats current as the destination entry, even when it points to a directory. Keeping both link names in the same parent enables rename replacement; ln -nfs does not provide that guarantee. Every deployment, rollback and cleanup must honor the lock. A local flock is insufficient for multiple hosts and may differ on network filesystems. The script deliberately leaves a failed deployment for inspection rather than automatically reverting database state or restarting services blindly.
migrate –force only skips a production confirmation. Use expand-and-contract changes that old web and queue code can tolerate: add structures, deploy compatible readers/writers, backfill in bounded batches, then enforce constraints or remove old structures after old processes and the rollback window are gone. Before NOT NULL, fill existing NULLs and stop old writers from creating more. Large backfills still create locks and I/O pressure; a feature flag does not make DDL nonblocking.
What About Env Changes?
Prepare required environment values before Composer scripts and cache generation. Set APP_DEBUG=false for production before generating the config cache. Shared .env changes can affect uncached old processes, while config-cached workers retain previous values; keep the transition compatible. Call env only from configuration files and rebuild the candidate’s config cache. Preserve APP_KEY and existing encryption compatibility. Restrict the cached config file too, because it can contain secrets. Do not run optimize:clear casually against shared production caches.
Queues and Horizon: Keeping Heavy Lifting Off the Front Door
Only work that can safely finish later belongs in an asynchronous queue; the sync driver stays in the request. Horizon requires Redis queues and its dashboard needs authorization. Queue availability, completion status and error handling are part of application behavior, not merely a way to hide slow requests.
Systemd Process Management
Supervisor and systemd can both manage Horizon or standalone queue workers; Horizon is not their replacement. Choose one manager per process. These systemd units assume matching PHP 8.3 CLI extensions, working shared permissions and a resolvable current path at every start. A stop timeout must exceed the longest valid job plus shutdown margin, and queue blocking must allow signal handling. The values below are examples to test, not universal defaults.
# /etc/systemd/system/horizon.service
[Unit]
Description=Laravel Horizon
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
UMask=0007
Restart=always
RestartSec=5
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/php8.3 /var/www/myapp/current/artisan horizon
Environment=APP_ENV=production
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec=3600
[Install]
WantedBy=multi-user.target
After adapting the unit and application’s Horizon settings, load and start it. After=network.target is ordering, not proof that Redis is reachable; inspect startup logs and actual job processing. systemd can eventually kill processes at the configured deadline, so stop success and job outcomes need verification.
sudo systemctl daemon-reload
sudo systemctl enable --now horizon
A standalone worker can instead handle a queue that Horizon does not also consume under a conflicting configuration. The example below uses a separate background queue and an explicit timeout. Make retry_after longer than that timeout for Redis/database queues; SQS uses visibility timeout instead. Install signal support and avoid indefinite blocking that prevents termination handling.
# /etc/systemd/system/queue-background.service
[Unit]
Description=Laravel Queue Worker (background)
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
UMask=0007
Restart=always
RestartSec=5
WorkingDirectory=/var/www/myapp/current
Environment=APP_ENV=production
ExecStart=/usr/bin/php8.3 /var/www/myapp/current/artisan queue:work --queue=background --sleep=1 --tries=3 --timeout=120 --max-time=3600
KillSignal=SIGTERM
KillMode=control-group
TimeoutStopSec=300
[Install]
WantedBy=multi-user.target
Horizon balances configured Redis queues according to its selected strategy; queue order under auto balancing is not strict priority. Set explicit capacity and timeout limits and track job age, runtime and failure rates. The official Horizon documentation must match the installed package. Test how the master drains its children; KillMode=mixed still permits final killing when the main exits or the stop deadline expires.
Coordinate Worker Shutdown and Release Activation
The deployment script uses an explicit service-manager stop so Restart=always cannot immediately revive the old release. Prepare and health-check the candidate first, then stop consumers and wait for a successful drain before switching. New producers must remain compatible with queued payloads during this short consumption pause. Coordinate all relevant worker services, not only Horizon if others are installed.
The following is the worker-control portion of the same sequence, not an additional command block to run after it. systemctl stop waits for the unit’s stop operation, but a timeout or forced kill is a failed graceful transition. Inspect Result, the service cgroup and job state before proceeding. Do not hide errors with || true.
# Candidate preparation and health checks have already passed.
sudo systemctl stop horizon
test "$(sudo systemctl show horizon -p Result --value)" = success
test "$(sudo systemctl show horizon -p SubState --value)" = dead
# Perform the locked atomic switch from the deployment script here.
sudo systemctl start horizon
sudo systemctl is-active --quiet horizon
# Also verify the new release identity and actual job processing.
Alternatively, compatible deployments can signal Horizon termination after activation and let the manager restart it from the new current path. That allows old and new work to overlap and requires tested schema, payload and cache compatibility. Blue-green workers also need intentional queue ownership and duplicate-effect handling. Neither pattern guarantees uninterrupted queue latency or exactly-once effects.
Retries, Failures, and Preventable Pain
Bound retries, runtime and backoff, and make externally visible effects idempotent. Unique-job locks do not make payment, email or API effects exactly once. Set job timeout below Horizon’s supervisor timeout and keep the latter below retry_after where applicable; size the process-manager stop deadline for the longest valid job. Separate slow workloads so one queue cannot consume every worker or database connection.
Config Caches, OPcache, and a Quick Word on Octane
Generate config, route and view caches in the candidate after environment and storage preparation. Its compiled views must use the release-local path, not shared storage/framework/views. OPcache stores PHP bytecode separately; validate_timestamps=0 requires a verified refresh strategy for changed files. A CLI opcache_reset does not establish FPM cache invalidation. Check FPM worker release identity, realpath behavior and any preload configuration before relying on a reload.
Octane uses long-lived application workers and introduces additional state-lifetime requirements. A reload must resolve the intended new release rather than keep an old working directory or preloaded code. It is a separate deployment design from this FPM example. The Laravel production optimization guide covers those trade-offs; benchmark before adopting another runtime.
Rolling Back Without Breaking a Sweat
A rollback changes current back to preserved code only after checking schema, data, environment, cache formats and queued-payload compatibility. Use the same deployment lock and worker stop/wait/switch/start sequence, including the tested FPM lifecycle and post-switch health gate. A migration down method can be destructive and is not proof that data is recoverable. Prefer a planned forward correction when reverting the schema would lose new writes.
A feature flag can reduce exposure when the application supports both states. It does not undo data already written or make incompatible migrations safe. Record which flags, configuration and schema states each rollback target supports.
Logs, Metrics, and Knowing When Something’s Off
Attach a release identifier to application log context and deployment records, and correlate it with HTTP and worker failures. Do not use an ORM global scope as a substitute for logging context. Exclude secrets and personal payloads from routine logs. Keep the previous release identity available even if a health gate fails after activation.
Monitor latency distributions, 5xx rates, FPM queues, resource use and job age/completion alongside uptime. The Prometheus, Grafana and Uptime Kuma guide provides a starting point. Define what failure triggers investigation, traffic restriction or rollback; an HTTP 200 alone is insufficient.
Security and TLS Without the Drama
Use a dedicated deploy account, narrow service permissions and verified SSH host keys. Prepopulate known_hosts using a fingerprint checked through a separate trusted channel and require StrictHostKeyChecking=yes in CI; do not disable verification to silence a changed-key error. Patch supported software and test edge rate limits and WAF rules. The ModSecurity and OWASP CRS tuning guide covers application-specific false-positive handling.
Validate Nginx configuration before reloading when it changes, and test HTTPS, redirects and proxy trust independently of the release switch. The official Nginx documentation explains directive behavior; a successful config test does not prove the application or certificate flow is correct.
Backups, Snapshots, and “Oops” Protection
Back up databases, user uploads and required configuration/keys to independent storage and test restoration. Release directories and VPS snapshots alone do not prove application-consistent recovery. Define retention, encryption and recovery objectives, then measure restore time. The Restic and Borg backup guide discusses options; verify each tool’s supported storage path.
End-to-End Deployment Acceptance
CI tests and builds an identified artifact. Under a deployment lock, prepare a new release, shared links and permissions before application scripts, then dependencies and local caches. Apply only reviewed compatible migrations, validate the candidate, stop and drain consumers, atomically switch current and start the required processes from the new path. Check the active HTTP flow, release identity and jobs before declaring success. Failure at any stage requires inspecting current code and service state rather than assuming the old version is still active.
Retention is a separate locked operation. Resolve and exclude current, the recorded rollback target and every release still used by requests, workers or schedulers. Keep prior public assets for the lifetime of old HTML/client references, even if application code can be retired earlier. Only remove explicitly verified real directories beneath releases after the drain and retention window; use structured pathname handling rather than an unquoted ls/tail/xargs pipeline. Deployer’s keep_releases count does not itself prove those conditions.
Troubleshooting: The Little Things That Bite
For route 404s, inspect the public root and front-controller fallback. For stale assets, verify versioned URLs and availability of old files; a purge does not repair every cached HTML reference. For stalled jobs, check Redis, queue names, paused state and worker release identity. For 502s, inspect socket access, FPM limits and upstream errors. For missing compiled views, confirm view.compiled is release-local before running view:cache again.
Rehearse successful and failed deployments in staging with isolated data, test payments and suppressed external notifications. Include concurrent requests during the switch, long-running jobs, a failed candidate health gate and a rollback after activation. Record which outcomes were tested; shell syntax checks alone are not a live deployment rehearsal.
A Quick Note on Hardware Sizing
There is no universal extra-20-percent sizing rule. Measure normal and peak demand plus temporary deployment overlap, Composer/build work and cache refill. Budget CPU, memory, storage latency and network limits together. The VPS sizing guide for Laravel, WooCommerce and Node.js explains workload measurements rather than fixed package promises.
Extras That Make Life Nicer
A health endpoint should report the specific readiness conditions you implement; Laravel’s default /up route does not automatically prove every database, cache and queue is healthy. Keep liveness and dependency readiness distinct. PHP preloading predates PHP 8.2 and requires a deliberate process lifecycle during code changes. Preserve a release manifest and verify CDN cache policy against actual public/private responses.
Keep Deployment Behavior Verifiable
Release directories, correct public routing and coordinated workers provide a useful foundation. The deployment is successful only when the new code serves expected responses, persistent data remains correct and jobs continue with compatible definitions. Atomic symlink replacement protects one filesystem transition; monitoring, backups and application-specific compatibility checks cover the rest.
Keep the artifact identity, environment requirements, health results and recovery procedure together. Recheck the sequence after Laravel, PHP, Horizon, filesystem or service-manager changes. A documented failed-deployment path is as important as the successful path.
Further Reading and Handy Docs
Use the Laravel deployment documentation for the installed version and the Nginx reference for routing and process behavior. The Nginx TLS and Brotli guide covers HTTPS configuration. Deployer’s Laravel recipe can automate the sequence, while health gates, shared-state compatibility and retirement rules still need application-specific design.





