How to Deploy Laravel Securely with GitHub Actions, Immutable Releases and Automatic Rollback

B
Bob Mwenda
Published • 8 min read
How to Deploy Laravel Securely with GitHub Actions, Immutable Releases and Automatic Rollback

A production deployment should do more than copy the latest code to a server. It should prove that the code passed its tests, build one identifiable release, protect production credentials, switch traffic without exposing a half-written application, restart long-running workers safely, and retain a known-good release for recovery.

This guide builds that pipeline for a Laravel application hosted on an Ubuntu VPS with Nginx, PHP-FPM, MySQL, Redis, Laravel Horizon, Supervisor, and GitHub Actions.

The example application is public at https://app.example.com. example.com is a reserved documentation domain, so none of the hostnames, users, passwords, addresses, or repository names in this article refer to a real system.

The finished deployment has these properties:

  • Pull requests and pushes run automated checks before production can be changed.
  • Only a successful push to main can start a production deployment.
  • GitHub Actions builds one release archive from the exact tested commit.
  • The production server never runs git pull and does not need access to the GitHub repository.
  • Production .env files, database passwords, application keys, and provider credentials never enter the release archive.
  • A dedicated SSH key can invoke only one fixed deployment receiver. It cannot open an interactive shell.
  • The receiver verifies the requested command, archive digest, archive size, archive paths, file types, and required release files.
  • Every deployment uses a new release directory.
  • The current symlink changes in one filesystem rename, so Nginx sees either the old release or the new release.
  • Horizon and the Laravel scheduler run under Supervisor and are reloaded after activation.
  • Local HTTPS health checks verify both Laravel startup and the deployed commit.
  • A failed post-activation health check restores the previous code symlink and restarts the previous workers.

The rollback described here is a code rollback. It cannot reverse database migrations, undo a job that already ran, recover deleted data, or cancel an external API call. Those limits affect how migrations and background jobs must be designed.

1. Deployment architecture

The release path is:

Developer pushes a commit
        |
        v
GitHub Actions runs application and build checks
        |
        +-- failure: production is not changed
        |
        v
The deploy job confirms this is still the latest main commit
        |
        v
GitHub installs locked production dependencies and builds assets
        |
        v
GitHub creates an archive without .env, storage, tests, or Git metadata
        |
        v
GitHub calculates SHA-256 and verifies the VPS SSH host fingerprint
        |
        v
A restricted SSH key streams the archive to the release receiver
        |
        v
The receiver validates and extracts a new immutable release
        |
        v
The receiver links protected configuration and shared storage
        |
        v
Backward-compatible migrations run with a separate migration account
        |
        v
Laravel caches are built with the normal runtime account configuration
        |
        v
The current symlink switches atomically to the new release
        |
        v
Horizon and the scheduler reload, then HTTPS health checks run
        |
        +-- failure: restore the previous code release and workers
        |
        v
The deployment is recorded as successful

This design has four trust boundaries:

  1. The repository and workflow: reviewed code and workflow changes determine what the runner builds.
  2. The GitHub production environment: branch rules, approvals, secrets, and concurrency determine when deployment is allowed.
  3. The SSH transport: a dedicated key and verified host fingerprint protect the runner-to-server connection.
  4. The server receiver: a fixed command validates the artifact and controls exactly which paths and services can change.

SHA-256 verifies that the archive received by the server is the archive the runner sent. It does not prove that the workflow, dependencies, or source code are trustworthy. Branch protection, code review, pinned actions, dependency review, and limited workflow permissions remain part of the security model.

2. Example values and assumptions

The guide uses the following example contract:

Setting Example value
Application Demo App
Application slug demo-app
Public hostname app.example.com
Repository example-org/demo-app
Production branch main
VPS operating system Ubuntu 24.04 LTS
PHP 8.3
Node.js 22
Database MySQL 8.4
Queue Redis with Laravel Horizon
Web server Nginx
Process monitor Supervisor
Application root /var/www/app.example.com
Deployment user deploy-demo
Application service user/group demo-app
PHP-FPM socket /run/php/php8.3-fpm-demo-app.sock
GitHub environment production
GitHub deploy-key secret DEMO_APP_DEPLOY_KEY

Do not copy version numbers without checking the target project. Laravel 13 requires PHP 8.3 or newer, but the application lock file may impose additional requirements. The example uses PHP 8.3 because it satisfies that baseline and is available from Ubuntu 24.04's supported packages. Use the versions represented by composer.lock, package-lock.json, .nvmrc, or the engines field in package.json, and update every runner, binary, service, socket, and path together when choosing another PHP version.

Node.js runs on the GitHub-hosted packaging runner in this design. It does not need to be installed on the production VPS.

The server package installation commands vary by operating system and package source. This guide assumes the following programs are installed before application provisioning:

  • Nginx
  • a supported PHP CLI and PHP-FPM release
  • the PHP extensions required by composer.json
  • MySQL or access to a managed MySQL service
  • Redis or access to a managed Redis service
  • Supervisor
  • Certbot
  • OpenSSH server
  • curl, tar, gzip, flock, sha256sum, and Python 3

For a new Ubuntu 24.04 host, this installs the baseline operating-system packages used by the guide. Review the package plan before accepting it and add the PHP extensions required by the real composer.lock:

sudo apt update

sudo apt install --no-install-recommends \
  ca-certificates \
  certbot \
  coreutils \
  curl \
  default-mysql-client \
  gzip \
  nginx \
  openssh-server \
  php8.3-bcmath \
  php8.3-cli \
  php8.3-curl \
  php8.3-fpm \
  php8.3-intl \
  php8.3-mbstring \
  php8.3-mysql \
  php8.3-redis \
  php8.3-xml \
  php8.3-zip \
  python3 \
  redis-server \
  supervisor \
  tar \
  util-linux

sudo systemctl enable --now \
  nginx \
  php8.3-fpm \
  redis-server \
  supervisor \
  ssh

This does not install MySQL Server 8.4. Use a supported managed MySQL 8.4 service or follow MySQL's official repository and installation documentation. If an existing supported MySQL release is already managed on the VPS, do not replace it only to match the example. Confirm the application's SQL behavior and migration plan against the selected release.

Do not install every PHP extension indiscriminately. Missing required extensions should fail composer check-platform-reqs; unused extensions increase patching and configuration surface.

Confirm the actual binary and service names on the server:

cat /etc/os-release
php -v
php-fpm8.3 -v
nginx -v
mysql --version
redis-cli --version
supervisord --version
certbot --version
python3 --version
command -v supervisorctl
command -v sha256sum
command -v flock

If a command is missing, install it through the supported package source for the server. Do not add an unreviewed third-party package repository to production merely to make the example version numbers match.

3. Write down the deployment contract first

Before changing DNS, creating users, or writing workflow YAML, record the real values for the application:

Repository:                    <OWNER>/<REPOSITORY>
Production branch:             main
Application name:              <APP_NAME>
Application slug:              <APP_SLUG>
Public hostname:               <DOMAIN>
VPS hostname or IP:            <VPS_HOST>
SSH port:                      <SSH_PORT>
Deployment user:               <DEPLOY_USER>
Application service user:      <SERVICE_USER>
Application root:              /var/www/<DOMAIN>
PHP binary:                    <ABSOLUTE_PHP_PATH>
PHP-FPM service:               <PHP_FPM_SERVICE>
PHP-FPM socket:                <PHP_FPM_SOCKET>
Database engine and version:   <DATABASE>
Runtime database user:         <RUNTIME_DB_USER>
Migration database user:       <MIGRATION_DB_USER>
Redis endpoint:                <REDIS_ENDPOINT>
Redis isolation model:         <DEDICATED_OR_APPROVED_SHARED>
Horizon queues:                <QUEUE_NAMES>
Longest expected job:          <SECONDS>
Health route:                  /up
Release identity route:        /_health/release
GitHub environment:            production
GitHub deploy key secret:      <APP>_DEPLOY_KEY
SSH host fingerprint variable: VPS_SSH_HOST_FINGERPRINT

Also decide:

  • whether the application has a frontend build;
  • whether it stores local uploads;
  • whether uploads are public or private;
  • whether it runs Horizon, ordinary queue workers, Reverb, Octane, or other long-running processes;
  • whether scheduled tasks run on every server or only one server;
  • whether a deployment may run migrations automatically;
  • which dependencies must make readiness fail;
  • who approves production deployments;
  • how database backups are created, tested, and restored;
  • how alerts reach the responsible engineer.

This guide enables Horizon and a scheduler because they are common in production Laravel applications. Remove those components only when the target application does not use them.

4. Prepare the repository

4.1 Confirm locked dependencies and repeatable commands

Run the project locally using the same major PHP and Node.js versions planned for CI and production:

git status --short --branch
composer validate --strict
composer install --prefer-dist --no-interaction --no-progress
npm ci
php artisan --version
php artisan test
npm run build

Commit both composer.lock and package-lock.json. A production build should install locked versions rather than resolve new dependency versions during each deployment.

Review the project for:

  • a safe .env.example containing names and examples, not real credentials;
  • APP_DEBUG=false in production;
  • a working Laravel health route;
  • no direct env() calls outside configuration files when using config caching;
  • queue names that separate fast transactional work from long batch work;
  • idempotent jobs where retries or duplicate callbacks are possible;
  • migration behavior on the real database engine and expected table sizes;
  • storage paths that must survive release replacement;
  • private uploads kept outside the public document root;
  • tests that do not send chargeable provider requests;
  • frontend build-time environment variables that are safe to embed in browser assets.

Any value exposed to Vite through a client-visible prefix becomes part of the built JavaScript. Do not pass server credentials or private API keys into a frontend build.

4.2 Add a release identity file

The workflow will create a file named REVISION containing the deployed Git commit. The receiver verifies it before activation. The application can expose it through a local-only health route.

Add this route to routes/web.php, or place the equivalent route in a route file loaded by the application:

<?php

use Illuminate\Support\Facades\Route;

Route::get('/_health/release', function () {
    $revisionFile = base_path('REVISION');

    abort_unless(is_file($revisionFile), 503);

    $revision = trim((string) file_get_contents($revisionFile));

    abort_unless(
        preg_match('/\A[a-f0-9]{40,64}\z/', $revision) === 1,
        503,
    );

    return response($revision, 200)
        ->header('Content-Type', 'text/plain; charset=UTF-8')
        ->header('Cache-Control', 'no-store');
});

The Nginx configuration later in this guide permits that route only from loopback. The public Laravel /up route remains available for normal uptime monitoring.

If your application must not expose /up publicly, restrict it through Nginx or application middleware and point the uptime monitor through an authenticated or private path.

4.3 Check Laravel storage paths

Laravel must be able to write to storage and bootstrap/cache. The release process will use shared persistent storage and a release-specific bootstrap/cache directory.

If the project defines additional local disk paths, add them to the shared layout before the first deployment. Do not leave user uploads inside an individual release directory.

Deployment commands and runtime processes use different Unix accounts, so shared log files must remain group-writable. In config/logging.php, set an explicit mode on file-based production channels. A daily channel can include:

'daily' => [
    'driver' => 'daily',
    'path' => storage_path('logs/laravel.log'),
    'level' => env('LOG_LEVEL', 'info'),
    'days' => 14,
    'permission' => 0660,
    'locking' => true,
],

Apply the same principle to any custom stream channel. Do not make application logs world-readable to solve an ownership problem.

5. Create server users, groups, and directories

Use a dedicated deployment account and a separate non-login service account. The deployment account owns release directories. PHP-FPM, Horizon, and the scheduler run as the service account.

Run these commands as an administrator after confirming the names do not already belong to another application:

sudo addgroup --system demo-app

sudo adduser \
  --disabled-password \
  --gecos '' \
  --shell /bin/bash \
  deploy-demo

sudo adduser \
  --system \
  --home /var/www/app.example.com \
  --no-create-home \
  --shell /usr/sbin/nologin \
  --ingroup demo-app \
  demo-app

sudo usermod -aG demo-app deploy-demo

The deployment user needs a valid shell because OpenSSH runs a forced command through the account's shell. Its password remains disabled, and the deployment key will be restricted to the release receiver.

Create the fixed directory layout:

sudo install -d -o deploy-demo -g demo-app -m 2751 \
  /var/www/app.example.com

sudo install -d -o deploy-demo -g demo-app -m 2751 \
  /var/www/app.example.com/releases

sudo install -d -o deploy-demo -g demo-app -m 0700 \
  /var/www/app.example.com/.incoming

sudo install -d -o deploy-demo -g demo-app -m 2771 \
  /var/www/app.example.com/shared

sudo install -d -o deploy-demo -g demo-app -m 2771 \
  /var/www/app.example.com/shared/storage \
  /var/www/app.example.com/shared/storage/app

sudo install -d -o deploy-demo -g demo-app -m 2770 \
  /var/www/app.example.com/shared/storage/app/private \
  /var/www/app.example.com/shared/storage/framework \
  /var/www/app.example.com/shared/storage/framework/cache/data \
  /var/www/app.example.com/shared/storage/framework/sessions \
  /var/www/app.example.com/shared/storage/framework/testing \
  /var/www/app.example.com/shared/storage/framework/views \
  /var/www/app.example.com/shared/storage/logs

sudo install -d -o deploy-demo -g demo-app -m 2775 \
  /var/www/app.example.com/shared/storage/app/public

sudo install -d -o root -g root -m 0755 \
  /var/www/acme/app.example.com/.well-known/acme-challenge

sudo install -d -o demo-app -g demo-app -m 0750 \
  /var/log/demo-app

The application root and release container allow other local users to traverse, but not list, the path. The receiver later makes only the release's public tree world-readable. Nginx therefore does not need membership in the demo-app group and cannot read the protected runtime environment file.

The shared storage parents also allow traversal because Nginx must reach storage/app/public. Private storage, framework state, logs, and secrets remain group-only. The public disk directory is intentionally readable because its contents are served publicly. Keep private uploads under storage/app/private, and confirm Laravel's public disk creates public files and directories with the intended visibility.

The set-group-ID bit on the shared writable directories helps new files inherit the demo-app group. Confirm permissions using the actual service account:

sudo -u demo-app touch /var/www/app.example.com/shared/storage/logs/permission-test
sudo -u demo-app rm /var/www/app.example.com/shared/storage/logs/permission-test

Start a new login session for deploy-demo after changing its group membership. Existing sessions do not automatically receive new supplementary groups.

The final layout will be:

/var/www/app.example.com/
  .deploy.lock
  .incoming/
  releases/
    <commit>-<run-id>-<attempt>/
  current -> releases/<active-release>
  shared/
    storage/

/etc/demo-app/
  app.env
  migrate.env

/var/www/acme/app.example.com/
  .well-known/acme-challenge/

6. Provision MySQL with separate runtime and migration users

The web application normally needs data manipulation privileges. It does not need permission to create users, grant privileges, or administer the database server. Migrations need scoped schema-change privileges, so this guide uses a separate migration account.

Generate strong passwords on a trusted machine or through an approved secret manager. A hexadecimal value is convenient for the shell-format migration file used later:

openssl rand -hex 32
openssl rand -hex 32

Connect to MySQL using an authorized provisioning account. Replace both example passwords before executing the SQL:

CREATE DATABASE `demo_app`
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

CREATE USER 'demo_app_runtime'@'127.0.0.1'
  IDENTIFIED BY 'REPLACE_WITH_RUNTIME_PASSWORD';

GRANT SELECT, INSERT, UPDATE, DELETE
  ON `demo_app`.*
  TO 'demo_app_runtime'@'127.0.0.1';

CREATE USER 'demo_app_migrator'@'127.0.0.1'
  IDENTIFIED BY 'REPLACE_WITH_MIGRATION_PASSWORD';

GRANT SELECT, INSERT, UPDATE, DELETE,
      CREATE, ALTER, DROP, INDEX, REFERENCES
  ON `demo_app`.*
  TO 'demo_app_migrator'@'127.0.0.1';

If migrations create views, triggers, routines, or temporary tables, grant only the additional privileges those reviewed migrations require. Do not solve a failed migration by granting global administrator privileges to the application account.

MySQL users include a host component. If Laravel connects through a Unix socket, a container network, or a remote database, use the exact host model approved for that deployment. Avoid a broad '%' account unless network design and database policy require it.

Verify both users without printing passwords into shell history. Use the interactive password prompt:

mysql -h 127.0.0.1 -u demo_app_runtime -p demo_app
mysql -h 127.0.0.1 -u demo_app_migrator -p demo_app

The runtime user should be able to read and change application data but should fail when asked to create or alter a table. The migration user should be able to run the application's reviewed migrations inside demo_app but should not have global server privileges.

7. Configure Redis and Horizon

Use a dedicated Redis instance or managed Redis service for the application when possible. Logical Redis databases are namespaces inside one server, not security, resource, persistence, or failure boundaries. Redis documentation recommends using logical databases to separate keys within one application, not to run unrelated applications in one shared instance.

Do not leave production Redis unauthenticated only because it listens on loopback. Create a dedicated application credential through the managed service or a tested Redis ACL. Keep the administrative credential separate, disable or tightly restrict destructive and administrative commands for the application identity, and retain network binding and firewall controls. Test Horizon's required commands before enforcing a custom ACL.

For the dedicated local Redis instance used in this example, generate separate hexadecimal application and administrator passwords:

openssl rand -hex 32
openssl rand -hex 32

Create /etc/redis/users.acl as root. Replace both password placeholders. Each user definition must remain on one line:

user default off
user demo_app on >REPLACE_WITH_APP_PASSWORD ~* &* +@all -ACL -BGSAVE -BGREWRITEAOF -CONFIG -DEBUG -FLUSHALL -FLUSHDB -KEYS -MIGRATE -MODULE -MONITOR -REPLICAOF -SAVE -SHUTDOWN -SLAVEOF
user demo_admin on >REPLACE_WITH_ADMIN_PASSWORD ~* &* +@all

Protect the file:

sudo chown root:redis /etc/redis/users.acl
sudo chmod 0640 /etc/redis/users.acl

Set this directive in /etc/redis/redis.conf:

aclfile /etc/redis/users.acl

Do not define users both inline in redis.conf and in an ACL file. Confirm that Redis remains bound to loopback or the intended private interface, keep an administration console open, then restart and test both identities with password prompts:

sudo systemctl restart redis-server
sudo systemctl status redis-server --no-pager

redis-cli --user demo_app --askpass PING
redis-cli --user demo_admin --askpass PING
redis-cli --user demo_admin --askpass ACL DRYRUN demo_app FLUSHDB

The two PING commands should return PONG. ACL DRYRUN should report that demo_app cannot run FLUSHDB; it evaluates permission without executing the destructive command. Store the administrator password outside the application configuration.

The command exclusions are a conservative starting point for a dedicated application instance, not a universal Redis ACL. Verify Horizon, cache, sessions, queues, rate limiting, locks, and any Pub/Sub behavior in a staging environment. If the application legitimately requires an excluded command, understand why before granting it. A shared Redis service needs stricter key and channel patterns plus resource and failure isolation; a dedicated instance remains the safer default.

This example assumes one Redis instance dedicated to Demo App:

  • Redis database 0 for queues and Horizon
  • Redis database 1 for cache and sessions
  • application-specific key prefixes

If Redis is shared, inventory every existing application before assigning database numbers. Use unique prefixes, network controls, authentication, resource monitoring, and carefully tested Redis ACLs. Key-pattern ACLs do not restrict keyspace-wide commands such as FLUSHALL and FLUSHDB, so destructive commands must also be denied explicitly.

Laravel Horizon currently requires Redis and is not compatible with Redis Cluster. Confirm the current Horizon documentation before selecting a Redis topology.

Use separate queue names for workloads with different urgency and runtime. A reasonable starting point is:

  • critical for short user-facing or callback work;
  • default for ordinary asynchronous work;
  • long for imports, exports, reports, or other slow work.

Example config/horizon.php production configuration:

<?php

return [
    // Other Horizon settings remain here.

    'environments' => [
        'production' => [
            'critical' => [
                'connection' => 'redis',
                'queue' => ['critical'],
                'balance' => 'auto',
                'autoScalingStrategy' => 'time',
                'minProcesses' => 1,
                'maxProcesses' => (int) env('HORIZON_CRITICAL_MAX_PROCESSES', 4),
                'maxTime' => 3600,
                'maxJobs' => 1000,
                'memory' => 256,
                'tries' => 3,
                'timeout' => 60,
            ],

            'default' => [
                'connection' => 'redis',
                'queue' => ['default'],
                'balance' => 'auto',
                'autoScalingStrategy' => 'time',
                'minProcesses' => 1,
                'maxProcesses' => (int) env('HORIZON_DEFAULT_MAX_PROCESSES', 3),
                'maxTime' => 3600,
                'maxJobs' => 1000,
                'memory' => 256,
                'tries' => 3,
                'timeout' => 120,
            ],

            'long' => [
                'connection' => 'redis-long',
                'queue' => ['long'],
                'balance' => 'auto',
                'autoScalingStrategy' => 'time',
                'minProcesses' => 1,
                'maxProcesses' => (int) env('HORIZON_LONG_MAX_PROCESSES', 1),
                'maxTime' => 3600,
                'maxJobs' => 100,
                'memory' => 512,
                'tries' => 2,
                'timeout' => 600,
            ],
        ],
    ],
];

This is a configuration shape, not a universal sizing recommendation. Measure queue wait time, job duration, memory, CPU, database contention, and provider rate limits before increasing worker counts.

Define the matching connections in config/queue.php. The long queue needs a retry_after value longer than its worker timeout:

'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 150),
        'block_for' => 5,
        'after_commit' => true,
    ],

    'redis-long' => [
        'driver' => 'redis',
        'connection' => 'default',
        'queue' => 'long',
        'retry_after' => (int) env('REDIS_LONG_QUEUE_RETRY_AFTER', 660),
        'block_for' => 5,
        'after_commit' => true,
    ],
],

Laravel recommends keeping the worker timeout several seconds shorter than retry_after. Otherwise a frozen job can be retried while its original worker is still running, which can cause duplicate processing.

Jobs that call external services or process callbacks should still be idempotent. Queue settings reduce some failure modes but do not guarantee exactly-once execution.

8. Create protected runtime configuration

Create a root-owned configuration directory outside the deploy-owned application tree. The deployment account is a member of demo-app, so it can traverse this directory and read app.env, but it cannot replace either protected file:

sudo install -d -o root -g demo-app -m 0710 /etc/demo-app

sudo install -o root -g demo-app -m 0640 /dev/null \
  /etc/demo-app/app.env

sudoedit /etc/demo-app/app.env

Example structure:

APP_NAME="Demo App"
APP_ENV=production
APP_KEY=base64:REPLACE_WITH_A_STABLE_PRODUCTION_KEY
APP_DEBUG=false
APP_URL=https://app.example.com
APP_TIMEZONE=UTC

LOG_CHANNEL=stack
LOG_STACK=daily
LOG_LEVEL=info

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=demo_app
DB_USERNAME=demo_app_runtime
DB_PASSWORD=REPLACE_WITH_RUNTIME_PASSWORD

CACHE_STORE=redis
SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
QUEUE_CONNECTION=redis

REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_USERNAME=demo_app
REDIS_PASSWORD=REPLACE_WITH_REDIS_APPLICATION_PASSWORD
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
REDIS_PREFIX=demo-app-database-
CACHE_PREFIX=demo-app-cache-
HORIZON_PREFIX=demo-app-horizon:

REDIS_QUEUE=default
REDIS_QUEUE_RETRY_AFTER=150
REDIS_LONG_QUEUE_RETRY_AFTER=660

HORIZON_CRITICAL_MAX_PROCESSES=4
HORIZON_DEFAULT_MAX_PROCESSES=3
HORIZON_LONG_MAX_PROCESSES=1

MAIL_MAILER=log

Replace the mail configuration and every application-specific integration value before production traffic is enabled. MAIL_MAILER=log is safe for initial infrastructure testing but does not deliver real email.

For a remote managed database or Redis service, use the provider's private-network and TLS settings. Configure the trusted CA through Laravel's database options, verify hostnames and certificates, and never make certificate verification failures disappear by disabling TLS checks. Keep CA files root-managed and readable by the application service account.

Generate APP_KEY on a trusted system using the same Laravel application, then transfer it through the approved secret process:

php artisan key:generate --show

Keep the production application key stable. Changing it can make previously encrypted values and cookies unreadable.

Create a second file containing only the migration-account overrides. Hexadecimal passwords make strict parsing straightforward:

sudo install -o root -g deploy-demo -m 0640 /dev/null \
  /etc/demo-app/migrate.env

sudo editor /etc/demo-app/migrate.env

Its content is:

MIGRATION_DB_USERNAME=demo_app_migrator
MIGRATION_DB_PASSWORD=REPLACE_WITH_HEXADECIMAL_MIGRATION_PASSWORD

The receiver parses these two assignments without executing the file. Do not add quotes, spaces, shell commands, or other variables.

Check ownership without printing file contents:

sudo stat -c '%U:%G %a %n' \
  /etc/demo-app/app.env \
  /etc/demo-app/migrate.env

Expected results:

root:demo-app 640 /etc/demo-app/app.env
root:deploy-demo 640 /etc/demo-app/migrate.env

9. Point DNS at the server

Create an A record for app.example.com that points to the VPS public IPv4 address. Add an AAAA record only when the server has working public IPv6, Nginx listens on IPv6, and the firewall permits the required IPv6 traffic.

The documentation-only example would look like this:

Type:  A
Name:  app
Value: 192.0.2.10
TTL:   300

192.0.2.10 is a reserved documentation address. Replace it with the real VPS address.

Check public resolution from more than one network before requesting a certificate:

dig +short A app.example.com
dig +short AAAA app.example.com

If Cloudflare manages DNS, keep the record DNS-only while testing the origin and obtaining the first certificate. After the origin works over HTTPS, the record can be proxied and the Cloudflare SSL/TLS mode can be set to Full (strict). Full (strict) verifies the certificate presented by the origin. A Cloudflare edge certificate alone does not secure the connection between Cloudflare and the VPS.

If VPS_HOST is a hostname such as deploy.example.com, keep that SSH hostname DNS-only unless an approved Cloudflare product is explicitly carrying SSH. Cloudflare's ordinary HTTP proxy does not forward a normal SSH connection. Protect the deployment hostname with its dedicated key, host fingerprint, firewall policy, and SSH restrictions rather than treating the orange-cloud setting as an SSH control.

Before opening ports, confirm that the host firewall and the provider firewall agree. A basic public-origin policy normally permits:

  • TCP 80 from the internet for HTTP redirects and ACME HTTP-01 validation;
  • TCP 443 from the internet for HTTPS;
  • the SSH port only from trusted administration addresses, plus GitHub-hosted runner addresses if the workflow connects directly from a GitHub-hosted runner.

GitHub-hosted runner address ranges change and are broad. If maintaining that allowlist is not acceptable, use a self-hosted runner on a controlled network, a private overlay network, or another approved deployment relay. Do not disable the firewall simply to make CI connectivity easier.

10. Configure HTTP and obtain the TLS certificate

Create an HTTP-only Nginx server block first. It serves ACME challenges from shared storage and redirects all other requests to HTTPS:

# /etc/nginx/sites-available/app.example.com
server {
    listen 80;
    listen [::]:80;

    server_name app.example.com;

    if ($host != app.example.com) {
        return 444;
    }

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme/app.example.com;
        default_type "text/plain";
        try_files $uri =404;
    }

    location / {
        return 301 https://app.example.com$request_uri;
    }
}

Enable the site and validate the complete Nginx configuration before reloading it:

sudo ln -s /etc/nginx/sites-available/app.example.com \
  /etc/nginx/sites-enabled/app.example.com

sudo nginx -t
sudo systemctl reload nginx

If the default Nginx site claims the same address, disable that default site only after confirming it is not used by another application:

sudo unlink /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Test the challenge path before invoking Certbot:

sudo -u root sh -c \
  'printf "%s\n" acme-test > /var/www/acme/app.example.com/.well-known/acme-challenge/test-file'

curl -fsS http://app.example.com/.well-known/acme-challenge/test-file

sudo rm /var/www/acme/app.example.com/.well-known/acme-challenge/test-file

Request a certificate with Certbot's webroot authenticator. Replace the example email address with a monitored operations address:

sudo certbot certonly \
  --webroot \
  --webroot-path /var/www/acme/app.example.com \
  --domain app.example.com \
  --email [email protected] \
  --agree-tos \
  --no-eff-email

Most supported Certbot packages install a timer or scheduled task for renewal. Confirm the timer and run the safe renewal simulation:

systemctl list-timers --all | grep certbot

Because the certificate was obtained with the webroot authenticator rather than the Nginx installer, add a deploy hook that validates and reloads Nginx after a successful renewal. Create /etc/letsencrypt/renewal-hooks/deploy/reload-nginx:

#!/bin/sh
set -eu

/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx

Protect the hook and run the renewal simulation with deploy hooks enabled:

sudo chown root:root /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
sudo certbot renew --dry-run --run-deploy-hooks

Confirm the absolute paths with command -v nginx and command -v systemctl before installing the hook. A renewal is not fully operational if the new certificate is written to disk but the serving process never reloads it.

Certificate issuance will fail if DNS points elsewhere, port 80 is blocked, the Cloudflare or reverse-proxy configuration intercepts the challenge incorrectly, or an incorrect AAAA record sends validation to a different server.

11. Configure the production HTTPS virtual host

Replace the temporary HTTP-only site with the full configuration below. This configuration sends only index.php to PHP-FPM. It does not execute arbitrary .php files that might appear beneath public.

# /etc/nginx/sites-available/app.example.com
server {
    listen 80;
    listen [::]:80;

    server_name app.example.com;

    if ($host != app.example.com) {
        return 444;
    }

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme/app.example.com;
        default_type "text/plain";
        try_files $uri =404;
    }

    location / {
        return 301 https://app.example.com$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name app.example.com;
    root /var/www/app.example.com/current/public;
    index index.php;

    if ($host != app.example.com) {
        return 444;
    }

    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:TLS:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    access_log /var/log/nginx/app.example.com.access.log;
    error_log /var/log/nginx/app.example.com.error.log;

    charset utf-8;
    client_max_body_size 20m;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location = /favicon.ico {
        access_log off;
        log_not_found off;
    }

    location = /robots.txt {
        access_log off;
        log_not_found off;
    }

    location ^~ /build/assets/ {
        try_files $uri =404;
        access_log off;
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location = /_health/release {
        allow 127.0.0.1;
        allow ::1;
        deny all;
        try_files $uri $uri/ /index.php?$query_string;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/php8.3-fpm-demo-app.sock;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_param HTTPS $https if_not_empty;
        fastcgi_param HTTP_PROXY "";
        fastcgi_hide_header X-Powered-By;
        internal;
    }

    location ~ \.php$ {
        return 404;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The $realpath_root value resolves the current symlink to the activated release. This gives PHP-FPM a release-specific script path and avoids handing it a path that still contains the moving symlink.

The hostname guard rejects requests carrying an unexpected Host value. If the application has an intentional alias, add it to server_name, the certificate, the guard, application trusted-host configuration, and redirect policy. On a VPS with several sites, also maintain explicit default catch-all servers rather than relying on whichever site Nginx loads first.

The release identity route is limited to the local machine. The regular Laravel /up health route remains public by default. If even that public readiness signal is not acceptable, place an authenticated external monitor in front of a separate route instead of relying only on localhost checks.

Validate and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Add HTTP Strict Transport Security only after HTTPS works correctly and the certificate renewal path has been tested. Start without includeSubDomains unless every subdomain is permanently HTTPS-only:

add_header Strict-Transport-Security "max-age=31536000" always;

Do not copy a generic Content Security Policy into production. Build one from the scripts, styles, fonts, images, frames, and API origins the application actually uses, deploy it in report-only mode first, inspect violations, and then enforce it.

11.1 Preserve client addresses behind Cloudflare

This subsection applies only when the DNS record is proxied by Cloudflare. Without a trusted real-IP configuration, Nginx and Laravel see a Cloudflare proxy address rather than the visitor address.

Cloudflare publishes its proxy networks. Generate a candidate snippet from the official lists, review every line, and install it only after that review:

cloudflare_real_ip_candidate="$(mktemp)"

{
  printf '%s\n' 'real_ip_header CF-Connecting-IP;'
  printf '%s\n' 'real_ip_recursive on;'

  curl -fsS https://www.cloudflare.com/ips-v4 | \
    while IFS= read -r cidr; do
      printf 'set_real_ip_from %s;\n' "$cidr"
    done

  curl -fsS https://www.cloudflare.com/ips-v6 | \
    while IFS= read -r cidr; do
      printf 'set_real_ip_from %s;\n' "$cidr"
    done
} > "$cloudflare_real_ip_candidate"

sed -n '1,200p' "$cloudflare_real_ip_candidate"

After checking that the file contains only real_ip_header, real_ip_recursive, and expected set_real_ip_from directives, install it:

sudo install -o root -g root -m 0644 \
  "$cloudflare_real_ip_candidate" \
  /etc/nginx/snippets/cloudflare-real-ip.conf

rm -f "$cloudflare_real_ip_candidate"

Add this line inside the HTTPS server block, then run sudo nginx -t and reload Nginx:

include /etc/nginx/snippets/cloudflare-real-ip.conf;

Update the snippet when Cloudflare changes its published address ranges. Never trust CF-Connecting-IP from arbitrary internet addresses. Nginx must accept that header only from the documented Cloudflare proxy networks. For stronger origin isolation, allow ports 80 and 443 only from Cloudflare networks after confirming how certificate validation and emergency direct-origin access will work.

12. Create a dedicated PHP-FPM pool

A separate PHP-FPM pool keeps the application processes under the demo-app service account and gives the site its own socket and process limits.

Create /etc/php/8.3/fpm/pool.d/demo-app.conf:

[demo-app]
user = demo-app
group = demo-app

listen = /run/php/php8.3-fpm-demo-app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 500

chdir = /var/www/app.example.com
clear_env = yes
catch_workers_output = yes
decorate_workers_output = no

php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/demo-app/php-fpm.log
php_admin_value[upload_tmp_dir] = /var/www/app.example.com/shared/storage/app/private
php_admin_value[session.save_path] = /var/www/app.example.com/shared/storage/framework/sessions

The sample process counts are not universal. Measure the memory consumed by a representative PHP-FPM worker, reserve memory for the operating system, Nginx, MySQL, Redis, queue workers, and filesystem cache, then set pm.max_children to a value the VPS can sustain under load. An oversized pool can exhaust memory and take down every service on the host.

Create the log file with restricted permissions, test the PHP-FPM configuration, and reload the service:

sudo install -o demo-app -g demo-app -m 0640 /dev/null \
  /var/log/demo-app/php-fpm.log

sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm

Use the binary and service names installed on the VPS. On some distributions the test command or configuration path is different.

13. Run Horizon and the scheduler with Supervisor

Laravel Horizon is a long-running process. php artisan schedule:work is also long-running. Both must be supervised and restarted after a new release is activated so that they load the new code and configuration.

Create /etc/supervisor/conf.d/demo-app-horizon.conf:

[program:demo-app-horizon]
process_name=%(program_name)s
command=/usr/bin/php8.3 /var/www/app.example.com/current/artisan horizon
directory=/var/www/app.example.com/current
user=demo-app
umask=007
autostart=false
autorestart=true
startsecs=5
startretries=3
stopsignal=TERM
stopwaitsecs=720
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/var/log/demo-app/horizon.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
environment=HOME="/var/www/app.example.com",USER="demo-app"

Create /etc/supervisor/conf.d/demo-app-scheduler.conf:

[program:demo-app-scheduler]
process_name=%(program_name)s
command=/usr/bin/php8.3 /var/www/app.example.com/current/artisan schedule:work
directory=/var/www/app.example.com/current
user=demo-app
umask=007
autostart=false
autorestart=true
startsecs=2
startretries=3
stopsignal=TERM
stopwaitsecs=90
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/var/log/demo-app/scheduler.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=10
environment=HOME="/var/www/app.example.com",USER="demo-app"

stopwaitsecs for Horizon must exceed the longest legitimate job duration. In this example, the long queue has a 600-second worker timeout, so Horizon receives 720 seconds to stop cleanly. The queue connection's retry_after value must still be several seconds longer than the worker timeout. Otherwise, a job can be made available to a second worker before the first worker has stopped processing it.

Leave autostart=false until the first release exists. Supervisor cannot start a command whose current/artisan path does not yet exist. Load the program definitions now:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status demo-app-horizon
sudo supervisorctl status demo-app-scheduler

STOPPED is expected before the first deployment.

The sample Supervisor configuration rotates captured process output. Laravel's daily log channel rotates application logs separately. If other unbounded files are added under /var/log/demo-app, add an explicit logrotate policy and test it with logrotate --debug before forcing a rotation.

Use either schedule:work under Supervisor or a once-per-minute cron entry that invokes schedule:run. Do not configure both, or scheduled tasks may run twice. On a multi-server deployment, use Laravel's single-server scheduling controls or designate one scheduler host.

Apply withoutOverlapping() to tasks that must not start while a prior run is active, and use onOneServer() where several scheduler hosts share a supported central cache. Choose lock expiry deliberately and provide an operational way to clear a stale lock only after confirming that the original task is no longer running.

14. Permit only the required service controls

The deployment receiver runs as deploy-demo. It needs to query, start, stop, and restart the two Supervisor programs, but it should not receive unrestricted sudo access. Stop permission is used to contain a failed first deployment when no earlier release exists.

Confirm the real supervisorctl path:

command -v supervisorctl

If it is /usr/bin/supervisorctl, create /etc/sudoers.d/demo-app-deploy with sudo visudo -f /etc/sudoers.d/demo-app-deploy:

Cmnd_Alias DEMO_APP_SUPERVISOR = \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf status demo-app-horizon, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf start demo-app-horizon, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf stop demo-app-horizon, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf restart demo-app-horizon, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf status demo-app-scheduler, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf start demo-app-scheduler, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf stop demo-app-scheduler, \
    /usr/bin/supervisorctl -c /etc/supervisor/supervisord.conf restart demo-app-scheduler

deploy-demo ALL=(root) NOPASSWD: DEMO_APP_SUPERVISOR

Set the required mode and validate the full sudoers configuration:

sudo chmod 0440 /etc/sudoers.d/demo-app-deploy
sudo visudo -cf /etc/sudoers

Test an allowed command and an intentionally unlisted command. The first should work without a password. The second should fail:

sudo -u deploy-demo sudo -n /usr/bin/supervisorctl \
  -c /etc/supervisor/supervisord.conf \
  status demo-app-horizon
sudo -u deploy-demo sudo -n /usr/bin/systemctl restart nginx

The fixed -c option prevents supervisorctl from discovering an account-controlled configuration file. Do not grant deploy-demo permission to run an editor, shell, package manager, arbitrary systemctl command, or an unrestricted Supervisor command as root.

15. Install the restricted release receiver

The release receiver is the server-side control point for a deployment. It reads one archive from standard input, validates it, creates a new release, runs migrations, switches current, reloads long-running processes, checks the application, and restores the previous code release when a post-activation step fails.

The receiver is a policy gate, not a sandbox. Laravel migrations and Artisan bootstrapping execute code from the release with access to runtime configuration, the migration credential, shared storage, MySQL, Redis, and the server's permitted network. A person who can merge deployment code and authorize production can therefore exercise application-level production privileges. Protect the branch, workflow, environment, and reviewers accordingly.

The script below is deliberately complete. Do not replace its validation with a short tar -xzf command that extracts untrusted paths directly into the application directory.

Create /usr/local/sbin/demo-app-deploy-release with this content:

#!/usr/bin/env bash
set -Eeuo pipefail

export PATH=/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C
umask 027

APP_DIR="/var/www/app.example.com"
RELEASES_DIR="$APP_DIR/releases"
INCOMING_DIR="$APP_DIR/.incoming"
CONFIG_DIR="/etc/demo-app"
RUNTIME_ENV="$CONFIG_DIR/app.env"
SHARED_STORAGE="$APP_DIR/shared/storage"
MIGRATION_ENV="$CONFIG_DIR/migrate.env"
CURRENT_LINK="$APP_DIR/current"
LOCK_FILE="$APP_DIR/.deploy.lock"

DOMAIN="app.example.com"
PHP_BIN="/usr/bin/php8.3"
SUPERVISORCTL="/usr/bin/supervisorctl"
SUPERVISOR_CONFIG="/etc/supervisor/supervisord.conf"
HORIZON_PROGRAM="demo-app-horizon"
SCHEDULER_PROGRAM="demo-app-scheduler"

MAX_ARCHIVE_BYTES=536870912
MAX_EXPANDED_BYTES=2147483648
MAX_ARCHIVE_MEMBERS=100000
KEEP_RELEASES=5
HORIZON_RELOAD_TIMEOUT=720
REQUIRE_VITE_MANIFEST=1

archive_path=""
staging_dir=""
release_dir=""
previous_release=""
switched=0
failure_line="unknown"

log() {
  printf '[deploy] %s\n' "$*" >&2
}

die() {
  failure_line="${BASH_LINENO[0]:-unknown}"
  log "ERROR: $*"
  exit 1
}

is_safe_release_path() {
  local candidate canonical base parent
  candidate="$1"
  canonical="$(readlink -m -- "$candidate")" || return 1
  parent="${canonical%/*}"
  base="${canonical##*/}"

  [[ "$parent" == "$RELEASES_DIR" ]] || return 1
  [[ "$base" =~ ^([0-9a-f]{40}|[0-9a-f]{64})-[0-9]+-[0-9]+$ ]]
}

resolved_current_release() {
  local current
  [[ -L "$CURRENT_LINK" ]] || return 1
  current="$(readlink -f -- "$CURRENT_LINK")" || return 1
  is_safe_release_path "$current" || return 1
  printf '%s\n' "$current"
}

supervisor_status() {
  local program output
  program="$1"
  output="$(sudo -n "$SUPERVISORCTL" \
    -c "$SUPERVISOR_CONFIG" \
    status "$program" 2>&1 || true)"
  printf '%s\n' "$output"
}

program_is_running() {
  local output
  output="$(supervisor_status "$1")"
  [[ "$output" == *" RUNNING "* ]]
}

require_program_running() {
  local program output
  program="$1"
  output="$(supervisor_status "$program")"
  [[ "$output" == *" RUNNING "* ]] || \
    die "Supervisor program $program is not running: $output"
}

start_or_restart_program() {
  local program
  program="$1"

  case "$program" in
    "$HORIZON_PROGRAM"|"$SCHEDULER_PROGRAM") ;;
    *) die "Refusing to control an unknown Supervisor program" ;;
  esac

  if program_is_running "$program"; then
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      restart "$program"
  else
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      start "$program"
  fi

  require_program_running "$program"
}

horizon_pid() {
  local output
  output="$(supervisor_status "$HORIZON_PROGRAM")"
  sed -n \
    's/.* RUNNING .*pid \([0-9][0-9]*\),.*/\1/p' \
    <<< "$output"
}

reload_horizon() {
  local old_pid new_pid deadline

  if ! program_is_running "$HORIZON_PROGRAM"; then
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      start "$HORIZON_PROGRAM"
    require_program_running "$HORIZON_PROGRAM"
    return
  fi

  old_pid="$(horizon_pid)"

  if [[ -z "$old_pid" ]]; then
    start_or_restart_program "$HORIZON_PROGRAM"
    return
  fi

  (
    cd "$CURRENT_LINK"
    "$PHP_BIN" artisan horizon:terminate --no-interaction
  )

  deadline=$((SECONDS + HORIZON_RELOAD_TIMEOUT))

  while (( SECONDS < deadline )); do
    new_pid="$(horizon_pid)"

    if [[ -n "$new_pid" && "$new_pid" != "$old_pid" ]]; then
      require_program_running "$HORIZON_PROGRAM"
      return
    fi

    sleep 2
  done

  log "Horizon did not recycle before the timeout; requesting a Supervisor restart"
  start_or_restart_program "$HORIZON_PROGRAM"
}

atomic_point_current_at() {
  local target base temporary_link
  target="$1"

  is_safe_release_path "$target" || return 1

  base="${target##*/}"
  temporary_link="$APP_DIR/.current.$base.tmp"

  rm -f -- "$temporary_link" || return 1
  ln -s -- "releases/$base" "$temporary_link" || return 1
  mv -Tf -- "$temporary_link" "$CURRENT_LINK"
}

try_start_or_restart_program() {
  local program
  program="$1"

  case "$program" in
    "$HORIZON_PROGRAM"|"$SCHEDULER_PROGRAM") ;;
    *) return 1 ;;
  esac

  if program_is_running "$program"; then
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      restart "$program" || return 1
  else
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      start "$program" || return 1
  fi

  program_is_running "$program"
}

try_stop_program() {
  local program
  program="$1"

  case "$program" in
    "$HORIZON_PROGRAM"|"$SCHEDULER_PROGRAM") ;;
    *) return 1 ;;
  esac

  if program_is_running "$program"; then
    sudo -n "$SUPERVISORCTL" \
      -c "$SUPERVISOR_CONFIG" \
      stop "$program" || return 1
  fi
}

check_application() {
  local expected_revision attempt actual_revision
  expected_revision="$1"

  for attempt in 1 2 3 4 5 6 7 8 9 10; do
    if /usr/bin/curl \
      --fail \
      --silent \
      --show-error \
      --connect-timeout 3 \
      --max-time 10 \
      --noproxy '*' \
      --resolve "$DOMAIN:443:127.0.0.1" \
      "https://$DOMAIN/up" \
      >/dev/null; then

      actual_revision="$(/usr/bin/curl \
        --fail \
        --silent \
        --show-error \
        --connect-timeout 3 \
        --max-time 10 \
        --noproxy '*' \
        --resolve "$DOMAIN:443:127.0.0.1" \
        "https://$DOMAIN/_health/release" || true)"

      if [[ "$actual_revision" == "$expected_revision" ]]; then
        return 0
      fi
    fi

    sleep 2
  done

  return 1
}

rollback_current() {
  local current_after_failure previous_release_id previous_commit
  log "Attempting to restore the previous code release"

  if [[ -n "$previous_release" ]] && \
     is_safe_release_path "$previous_release" && \
     [[ -d "$previous_release" ]]; then
    if ! atomic_point_current_at "$previous_release"; then
      log "Could not restore the previous current symlink"
      return 1
    fi

    try_start_or_restart_program "$HORIZON_PROGRAM" || \
      log "Could not reload Horizon during rollback"

    try_start_or_restart_program "$SCHEDULER_PROGRAM" || \
      log "Could not reload the scheduler during rollback"

    previous_release_id="${previous_release##*/}"
    previous_commit="${previous_release_id%%-*}"

    check_application "$previous_commit" || \
      log "The restored release did not pass the release identity check"
  else
    current_after_failure="$(resolved_current_release || true)"

    try_stop_program "$HORIZON_PROGRAM" || \
      log "Could not stop Horizon after the failed first release"

    try_stop_program "$SCHEDULER_PROGRAM" || \
      log "Could not stop the scheduler after the failed first release"

    if [[ -n "$release_dir" && \
          "$current_after_failure" == "$release_dir" && \
          -L "$CURRENT_LINK" ]]; then
      rm -f -- "$CURRENT_LINK"
    fi

    log "No previous release was available to restore"
  fi

  switched=0
}

remove_release_safely() {
  local target current
  target="$1"

  is_safe_release_path "$target" || return 1
  current="$(resolved_current_release || true)"
  [[ "$target" != "$current" ]] || return 1
  [[ "$target" != "$previous_release" ]] || return 1

  rm -rf --one-file-system -- "$target"
}

prune_old_releases() {
  local path count current
  count=0
  current="$(resolved_current_release || true)"

  while IFS= read -r path; do
    is_safe_release_path "$path" || continue
    [[ -f "$path/.activated" ]] || continue

    count=$((count + 1))

    if (( count <= KEEP_RELEASES )); then
      continue
    fi

    [[ "$path" != "$current" ]] || continue
    [[ "$path" != "$previous_release" ]] || continue

    remove_release_safely "$path" || \
      log "Could not remove old release $path"
  done < <(
    find "$RELEASES_DIR" \
      -mindepth 1 \
      -maxdepth 1 \
      -type d \
      -printf '%T@ %p\n' | sort -rn | cut -d' ' -f2-
  )
}

on_exit() {
  local status="$?"
  local current_after_exit
  trap - EXIT ERR INT TERM HUP
  set +e

  if (( status != 0 )); then
    log "Deployment failed near script line $failure_line"

    if (( switched == 1 )); then
      rollback_current || log "Automatic code rollback did not complete"
    fi
  fi

  if [[ -n "$archive_path" ]]; then
    rm -f -- "$archive_path"
  fi

  if [[ -n "$staging_dir" && -d "$staging_dir" ]]; then
    rm -rf --one-file-system -- "$staging_dir"
  fi

  if (( status != 0 )) && \
     [[ -n "$release_dir" && -d "$release_dir" ]] && \
     is_safe_release_path "$release_dir"; then
    current_after_exit="$(resolved_current_release || true)"

    if [[ "$current_after_exit" != "$release_dir" ]]; then
      rm -rf --one-file-system -- "$release_dir"
    fi
  fi

  exit "$status"
}

trap 'failure_line=$LINENO' ERR
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP

original_command="${SSH_ORIGINAL_COMMAND:-}"
action=""
commit=""
expected_digest=""
run_id=""
run_attempt=""
extra=""

IFS=' ' read -r \
  action commit expected_digest run_id run_attempt extra \
  <<< "$original_command"

[[ "$action" == "deploy" ]] || die "Invalid deployment command"
[[ "$commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || \
  die "Invalid commit identifier"
[[ "$expected_digest" =~ ^[0-9a-f]{64}$ ]] || \
  die "Invalid SHA-256 digest"
[[ "$run_id" =~ ^[0-9]+$ ]] || die "Invalid workflow run identifier"
[[ "$run_attempt" =~ ^[0-9]+$ ]] || die "Invalid run attempt"
[[ -z "$extra" ]] || die "Unexpected deployment command arguments"

for required_command in \
  "$PHP_BIN" \
  "$SUPERVISORCTL" \
  /usr/bin/curl \
  /usr/bin/python3 \
  /usr/bin/sha256sum \
  /usr/bin/tar; do
  [[ -x "$required_command" ]] || \
    die "Required command is unavailable: $required_command"
done

[[ -d "$APP_DIR" && ! -L "$APP_DIR" ]] || \
  die "Application root is missing or unsafe"
[[ -d "$RELEASES_DIR" && ! -L "$RELEASES_DIR" ]] || \
  die "Release directory is missing or unsafe"
[[ -d "$INCOMING_DIR" && ! -L "$INCOMING_DIR" ]] || \
  die "Incoming directory is missing or unsafe"
[[ -d "$SHARED_STORAGE" && ! -L "$SHARED_STORAGE" ]] || \
  die "Shared storage directory is missing or unsafe"
[[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]] || \
  die "Protected configuration directory is missing or unsafe"
[[ -f "$RUNTIME_ENV" && ! -L "$RUNTIME_ENV" ]] || \
  die "Runtime environment file is missing or unsafe"
[[ -f "$MIGRATION_ENV" && ! -L "$MIGRATION_ENV" ]] || \
  die "Migration environment file is missing or unsafe"

[[ "$(stat -c '%U:%G:%a' "$CONFIG_DIR")" == "root:demo-app:710" ]] || \
  die "Protected configuration directory ownership or mode is incorrect"
[[ "$(stat -c '%U:%G:%a' "$RUNTIME_ENV")" == "root:demo-app:640" ]] || \
  die "Runtime environment file ownership or mode is incorrect"
[[ "$(stat -c '%U:%G:%a' "$MIGRATION_ENV")" == "root:deploy-demo:640" ]] || \
  die "Migration environment file ownership or mode is incorrect"

exec 9>"$LOCK_FILE"
flock -n 9 || die "Another deployment is already running"

release_id="$commit-$run_id-$run_attempt"
release_dir="$RELEASES_DIR/$release_id"

is_safe_release_path "$release_dir" || die "Unsafe release path"
[[ ! -e "$release_dir" && ! -L "$release_dir" ]] || \
  die "This workflow attempt already has a release directory"

if [[ -L "$CURRENT_LINK" ]]; then
  previous_release="$(resolved_current_release)" || \
    die "The current symlink does not point to a valid release"
elif [[ -e "$CURRENT_LINK" ]]; then
  die "The current path exists but is not a symlink"
fi

archive_path="$(mktemp "$INCOMING_DIR/archive.XXXXXX.tar.gz")"
staging_dir="$(mktemp -d "$INCOMING_DIR/staging.XXXXXX")"

log "Receiving release archive"

dd \
  iflag=fullblock \
  bs=1048576 \
  count=513 \
  status=none \
  of="$archive_path"

archive_size="$(stat -c '%s' "$archive_path")"

(( archive_size > 0 )) || die "The release archive is empty"
(( archive_size <= MAX_ARCHIVE_BYTES )) || \
  die "The release archive exceeds the configured size limit"

actual_digest="$(sha256sum "$archive_path" | awk '{print $1}')"
[[ "$actual_digest" == "$expected_digest" ]] || \
  die "The release archive digest does not match"

log "Validating archive structure"

python3 - \
  "$archive_path" \
  "$MAX_EXPANDED_BYTES" \
  "$MAX_ARCHIVE_MEMBERS" <<'PY'
import pathlib
import sys
import tarfile

archive_path = sys.argv[1]
max_expanded_bytes = int(sys.argv[2])
max_members = int(sys.argv[3])

blocked_roots = {
    ".git",
    ".github",
    ".ssh",
    "node_modules",
    "storage",
    "tests",
}

blocked_files = {
    ".npmrc",
    ".yarnrc.yml",
    "auth.json",
}

seen = set()
member_count = 0
expanded_bytes = 0

with tarfile.open(archive_path, mode="r:gz") as release:
    for member in release:
        member_count += 1

        if member_count > max_members:
            raise SystemExit("archive contains too many members")

        raw_name = member.name

        if not raw_name or "\\" in raw_name:
            raise SystemExit(f"invalid archive path: {raw_name!r}")

        if any(ord(character) < 32 for character in raw_name):
            raise SystemExit(f"control character in archive path: {raw_name!r}")

        if raw_name.startswith("/"):
            raise SystemExit(f"absolute archive path: {raw_name!r}")

        name = raw_name
        while name.startswith("./"):
            name = name[2:]

        if name in {"", "."}:
            if not member.isdir():
                raise SystemExit("archive root entry is not a directory")
            continue

        parts = name.split("/")

        if any(part in {"", ".", ".."} for part in parts):
            raise SystemExit(f"unsafe archive path: {raw_name!r}")

        normalized = pathlib.PurePosixPath(*parts).as_posix()

        if normalized in seen:
            raise SystemExit(f"duplicate archive path: {normalized!r}")
        seen.add(normalized)

        first = parts[0]

        if first in blocked_roots:
            raise SystemExit(f"blocked archive path: {normalized!r}")

        if normalized in blocked_files:
            raise SystemExit(f"package-manager credential file in archive: {normalized!r}")

        if first == ".env" or first.startswith(".env."):
            raise SystemExit(f"environment file in archive: {normalized!r}")

        if normalized == "public/storage" or normalized.startswith("public/storage/"):
            raise SystemExit(f"shared storage path in archive: {normalized!r}")

        if not (member.isfile() or member.isdir()):
            raise SystemExit(f"unsupported archive member type: {normalized!r}")

        if member.isfile():
            expanded_bytes += member.size

            if expanded_bytes > max_expanded_bytes:
                raise SystemExit("archive expands beyond the configured size limit")
PY

tar \
  --extract \
  --gzip \
  --file "$archive_path" \
  --directory "$staging_dir" \
  --no-same-owner \
  --no-same-permissions \
  --delay-directory-restore

mv -- "$staging_dir" "$release_dir"
staging_dir=""

[[ -f "$release_dir/artisan" ]] || die "Release is missing artisan"
[[ -f "$release_dir/public/index.php" ]] || \
  die "Release is missing public/index.php"
[[ -f "$release_dir/vendor/autoload.php" ]] || \
  die "Release is missing production Composer dependencies"
[[ -f "$release_dir/REVISION" ]] || die "Release is missing REVISION"

if (( REQUIRE_VITE_MANIFEST == 1 )); then
  [[ -f "$release_dir/public/build/manifest.json" ]] || \
    die "Release is missing the Vite manifest"
fi

revision="$(<"$release_dir/REVISION")"
[[ "$revision" == "$commit" ]] || \
  die "REVISION does not match the requested commit"

chgrp -R demo-app "$release_dir"
find "$release_dir" -type d -exec chmod 0750 {} +
find "$release_dir" -type f -exec chmod 0640 {} +
chmod 0751 "$release_dir"
find "$release_dir/public" -type d -exec chmod 0755 {} +
find "$release_dir/public" -type f -exec chmod 0644 {} +

install -d -m 2770 "$release_dir/bootstrap/cache"
chgrp demo-app "$release_dir/bootstrap/cache"

find "$release_dir/bootstrap/cache" \
  -mindepth 1 \
  -maxdepth 1 \
  -type f \
  \( \
    -name 'compiled.php' -o \
    -name 'config.php' -o \
    -name 'events.php' -o \
    -name 'routes*.php' \
  \) \
  -delete

ln -s -- "$RUNTIME_ENV" "$release_dir/.env"
ln -s -- "$SHARED_STORAGE" "$release_dir/storage"
ln -s -- "$SHARED_STORAGE/app/public" "$release_dir/public/storage"

migration_user=""
migration_password=""
migration_user_seen=0
migration_password_seen=0

while IFS= read -r migration_line || [[ -n "$migration_line" ]]; do
  case "$migration_line" in
    ''|'#'*) ;;
    MIGRATION_DB_USERNAME=*)
      (( migration_user_seen == 0 )) || \
        die "Duplicate migration username"
      migration_user="${migration_line#MIGRATION_DB_USERNAME=}"
      migration_user_seen=1
      ;;
    MIGRATION_DB_PASSWORD=*)
      (( migration_password_seen == 0 )) || \
        die "Duplicate migration password"
      migration_password="${migration_line#MIGRATION_DB_PASSWORD=}"
      migration_password_seen=1
      ;;
    *)
      die "Unsupported line in the migration environment file"
      ;;
  esac
done < "$MIGRATION_ENV"

[[ "$migration_user" =~ ^[A-Za-z0-9_]+$ ]] || \
  die "Invalid migration database username"
[[ "$migration_password" =~ ^[0-9A-Fa-f]{64}$ ]] || \
  die "Invalid migration database password format"

log "Running database migrations"

umask 0007

(
  cd "$release_dir"
  DB_USERNAME="$migration_user" \
  DB_PASSWORD="$migration_password" \
    "$PHP_BIN" artisan migrate --force --no-interaction
)

unset migration_user migration_password migration_line

log "Building Laravel production caches"

(
  cd "$release_dir"
  "$PHP_BIN" artisan optimize --no-interaction
)

find "$release_dir/bootstrap/cache" -type d -exec chmod 2770 {} +
find "$release_dir/bootstrap/cache" -type f -exec chmod 0660 {} +
umask 0027

log "Activating release $release_id"
switched=1
atomic_point_current_at "$release_dir"

reload_horizon
start_or_restart_program "$SCHEDULER_PROGRAM"

log "Checking Laravel and the active revision over local HTTPS"

check_application "$commit" || \
  die "The new release did not pass its health checks"

touch "$release_dir/.activated"
chmod 0640 "$release_dir/.activated"
prune_old_releases || log "Old-release pruning did not complete"

log "Deployment completed for commit $commit"

Set root ownership and make the script executable:

sudo chown root:root /usr/local/sbin/demo-app-deploy-release
sudo chmod 0755 /usr/local/sbin/demo-app-deploy-release
sudo bash -n /usr/local/sbin/demo-app-deploy-release

Set REQUIRE_VITE_MANIFEST=0 only if the application genuinely has no Vite build. If it has a different build output, replace that check with the real required artifact.

The receiver deletes only release-local generated configuration, event, and route cache files before Artisan starts. It does not call optimize:clear, because that command can also clear the application's shared cache store. Composer's package manifests remain available, and artisan optimize rebuilds the production caches after migration with the protected runtime environment.

The receiver retains five successful releases, excluding an active or immediately previous release that must be protected. Increase KEEP_RELEASES if releases are small and a longer local recovery window is useful. Old releases are not a backup of the database or user uploads.

15.1 What the automatic rollback does and does not do

If activation, process reload, or health checks fail after current changes, the exit handler points current back to the prior release and restarts its Horizon and scheduler processes. The failed release directory is removed when it is no longer active.

Migrations run before activation and are not reversed. An automatic migrate:rollback is unsafe because a down migration can lose data and an older application may have handled live traffic or jobs before failure was detected. Production migrations therefore need an expand-and-contract design, covered later in this guide.

The rollback check extracts the commit from the prior release directory name because the release identity route returns the commit stored in REVISION, not the full server directory name.

16. Create and restrict the deployment SSH key

Generate a dedicated key pair on an administrator workstation. Do not reuse a personal administration key or a key shared with another application:

ssh-keygen \
  -t ed25519 \
  -a 100 \
  -N '' \
  -C 'demo-app-github-actions' \
  -f ./demo-app-github-actions

The unencrypted private key is suitable for a non-interactive workflow only because its server-side capability is narrowly constrained. Protect it with the GitHub production environment, do not copy it to developer laptops after setup, and rotate it after suspected exposure or staff changes.

On the server, create the deployment account's SSH directory:

sudo install -d -o deploy-demo -g deploy-demo -m 0700 \
  /home/deploy-demo/.ssh

sudo install -o deploy-demo -g deploy-demo -m 0600 /dev/null \
  /home/deploy-demo/.ssh/authorized_keys

sudo -u deploy-demo editor /home/deploy-demo/.ssh/authorized_keys

Add the public key as one uninterrupted line. Replace the placeholder key body with the contents of demo-app-github-actions.pub:

restrict,command="/usr/local/sbin/demo-app-deploy-release" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI_REPLACE_WITH_THE_REAL_PUBLIC_KEY demo-app-github-actions

restrict disables port, agent, and X11 forwarding, disables PTY allocation, and disables user startup files for this key. The command option replaces any client command with the receiver while preserving the requested command in SSH_ORIGINAL_COMMAND.

Add an account-level restriction as a second layer. Create /etc/ssh/sshd_config.d/60-demo-app-deploy.conf:

Match User deploy-demo
    AuthenticationMethods publickey
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PermitTTY no
    AllowAgentForwarding no
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no
    PermitUserEnvironment no
    GatewayPorts no
    ForceCommand /usr/local/sbin/demo-app-deploy-release

Validate the SSH server configuration before reloading it. Keep the current administrative session open until a second session proves that administrative access still works:

sudo sshd -t
sudo systemctl reload ssh

Test the restricted key from the administrator workstation:

ssh \
  -i ./demo-app-github-actions \
  -o IdentitiesOnly=yes \
  deploy-demo@YOUR_VPS_HOST \
  'whoami'

The connection should return Invalid deployment command. It must not print deploy-demo or open a shell. If the server uses a nonstandard SSH port, add -p PORT.

Record the server's Ed25519 host-key fingerprint through a trusted administration channel:

sudo ssh-keygen \
  -lf /etc/ssh/ssh_host_ed25519_key.pub \
  -E sha256

Save the complete fingerprint shown in the second column. It begins with SHA256:. The workflow will compare it with the key returned over the network before connecting. ssh-keyscan by itself does not authenticate a server because an attacker in the network path could answer the scan.

17. Protect the GitHub production environment

In the GitHub repository, open Settings, then Environments, and create an environment named production.

Configure the strongest controls available for the repository and GitHub plan:

  1. Limit deployments to the main branch.
  2. Add required reviewers for production when the team has more than one trusted maintainer.
  3. Prevent self-review when that option is available.
  4. Add an environment wait timer only when it serves an operational purpose.
  5. Keep production secrets in the environment rather than at organization or repository scope unless several repositories genuinely need them.

Create this environment secret:

Name Value
DEMO_APP_DEPLOY_KEY Entire private key from demo-app-github-actions

Create these environment variables:

Name Example Purpose
VPS_HOST deploy.example.com SSH hostname or fixed server address
VPS_SSH_PORT 22 SSH port
VPS_SSH_HOST_FINGERPRINT SHA256:REPLACE_WITH_REAL_FINGERPRINT Trusted Ed25519 host-key fingerprint

Do not put the production .env, database password, APP_KEY, mail password, or provider credentials in this workflow. They remain in the root-owned runtime configuration on the server.

Protect main with a GitHub ruleset or branch protection rule. A practical policy requires:

  • changes through pull requests;
  • at least one review from a responsible maintainer;
  • dismissal of stale approvals after code changes;
  • resolution of review conversations;
  • the aggregate CI job from the next section;
  • a current branch before merge;
  • signed commits when the organization already has a workable signing policy;
  • protection against force pushes and deletion.

Restrict who can modify workflow files with CODEOWNERS, for example:

# .github/CODEOWNERS
/.github/workflows/ @example-org/platform-team
/scripts/deployment/ @example-org/platform-team

Replace the example team with a real team or maintainer. A CODEOWNERS entry is effective only when branch protection also requires code-owner review.

In Settings, Actions, General, set the default GITHUB_TOKEN permission to read-only and allow write permission only in jobs that need it. The sample workflow declares contents: read and does not need write access.

18. Add the GitHub Actions workflow

Create .github/workflows/ci-and-deploy.yml in the repository. The workflow below separates backend and frontend validation, exposes one aggregate CI status, builds the production release again from the exact commit, verifies that the commit is still the tip of main, pins third-party actions to full commit hashes, serializes production deployments, verifies the VPS host key, and streams the archive to the restricted receiver.

name: CI and production deployment

on:
  push:
    branches:
      - main
  pull_request:
  merge_group:

permissions:
  contents: read

jobs:
  backend:
    name: Backend checks
    runs-on: ubuntu-24.04
    timeout-minutes: 20

    env:
      APP_ENV: testing
      DB_CONNECTION: mysql
      DB_HOST: 127.0.0.1
      DB_PORT: 3306
      DB_DATABASE: demo_app_test
      DB_USERNAME: root
      DB_PASSWORD: test_password
      CACHE_STORE: array
      SESSION_DRIVER: array
      QUEUE_CONNECTION: sync
      MAIL_MAILER: array

    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: demo_app_test
          MYSQL_ROOT_PASSWORD: test_password
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping --host=127.0.0.1 --user=root --password=test_password"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10

    steps:
      - name: Check out the tested commit
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up PHP
        uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
        with:
          php-version: '8.3'
          extensions: bcmath, ctype, curl, dom, fileinfo, intl, mbstring, mysql, openssl, pcntl, pdo, redis, tokenizer, xml
          coverage: none
          tools: composer:v2

      - name: Validate Composer metadata
        run: composer validate --strict --no-interaction

      - name: Install locked Composer dependencies
        uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0
        with:
          dependency-versions: locked

      - name: Audit locked Composer dependencies
        run: composer audit --locked --no-interaction

      - name: Prepare the test application
        run: |
          cp .env.example .env
          php artisan key:generate --no-interaction
          php artisan migrate --force --no-interaction

      - name: Check PHP formatting
        run: |
          if [[ -x vendor/bin/pint ]]; then
            vendor/bin/pint --test
          fi

      - name: Run static analysis when configured
        run: |
          if [[ -x vendor/bin/phpstan ]]; then
            vendor/bin/phpstan analyse --no-progress
          fi

      - name: Run backend tests
        run: php artisan test

  frontend:
    name: Frontend checks
    runs-on: ubuntu-24.04
    timeout-minutes: 20

    steps:
      - name: Check out the tested commit
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: '22'
          cache: npm

      - name: Install locked Node.js dependencies
        run: npm ci

      - name: Audit Node.js dependencies
        run: npm audit --audit-level=high

      - name: Run frontend checks
        run: |
          npm run lint --if-present
          npm run typecheck --if-present
          npm run test:ci --if-present

      - name: Build frontend assets
        run: npm run build

      - name: Verify the Vite manifest
        run: test -f public/build/manifest.json

  ci:
    name: Required CI result
    if: ${{ always() }}
    needs:
      - backend
      - frontend
    runs-on: ubuntu-24.04
    steps:
      - name: Require every CI job to pass
        env:
          BACKEND_RESULT: ${{ needs.backend.result }}
          FRONTEND_RESULT: ${{ needs.frontend.result }}
        run: |
          [[ "$BACKEND_RESULT" == "success" ]]
          [[ "$FRONTEND_RESULT" == "success" ]]

  package:
    name: Package production release
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    needs:
      - ci
    runs-on: ubuntu-24.04
    timeout-minutes: 25

    steps:
      - name: Check out the exact release commit
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ github.sha }}
          persist-credentials: false

      - name: Set up PHP
        uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
        with:
          php-version: '8.3'
          extensions: bcmath, ctype, curl, dom, fileinfo, intl, mbstring, mysql, openssl, pcntl, pdo, redis, tokenizer, xml
          coverage: none
          tools: composer:v2

      - name: Install production Composer dependencies
        run: |
          export APP_ENV=production
          export APP_KEY="base64:$(php -r 'echo base64_encode(random_bytes(32));')"

          composer install \
            --no-dev \
            --prefer-dist \
            --classmap-authoritative \
            --no-interaction \
            --no-progress

          composer check-platform-reqs --no-dev

      - name: Set up Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: '22'
          cache: npm

      - name: Build production frontend assets
        run: |
          npm ci
          npm run build
          test -f public/build/manifest.json

      - name: Create the immutable release archive
        run: |
          printf '%s\n' "$GITHUB_SHA" > REVISION

          archive_path="$RUNNER_TEMP/demo-app-$GITHUB_SHA.tar.gz"

          tar \
            --create \
            --gzip \
            --file "$archive_path" \
            --directory "$GITHUB_WORKSPACE" \
            --exclude-vcs \
            --exclude='./.env' \
            --exclude='./.env.*' \
            --exclude='./.github' \
            --exclude='./.npmrc' \
            --exclude='./.yarnrc.yml' \
            --exclude='./auth.json' \
            --exclude='./node_modules' \
            --exclude='./public/storage' \
            --exclude='./storage' \
            --exclude='./tests' \
            .

          test -s "$archive_path"

      - name: Upload the release archive
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: demo-app-${{ github.sha }}
          path: ${{ runner.temp }}/demo-app-${{ github.sha }}.tar.gz
          if-no-files-found: error
          retention-days: 1
          compression-level: 0

  deploy:
    name: Deploy production
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    needs:
      - package
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    environment: production
    concurrency:
      group: demo-app-production
      cancel-in-progress: false

    env:
      DEPLOY_HOST: ${{ vars.VPS_HOST }}
      DEPLOY_PORT: ${{ vars.VPS_SSH_PORT }}
      EXPECTED_HOST_FINGERPRINT: ${{ vars.VPS_SSH_HOST_FINGERPRINT }}

    steps:
      - name: Confirm this is still the latest main commit
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          latest_sha="$(curl \
            --fail \
            --silent \
            --show-error \
            --header "Accept: application/vnd.github+json" \
            --header "Authorization: Bearer $GH_TOKEN" \
            --header "X-GitHub-Api-Version: 2022-11-28" \
            "https://api.github.com/repos/$GITHUB_REPOSITORY/git/ref/heads/main" | \
            jq -er '.object.sha')"

          if [[ "$latest_sha" != "$GITHUB_SHA" ]]; then
            printf '%s\n' \
              "Refusing to deploy $GITHUB_SHA because main is now $latest_sha" >&2
            exit 1
          fi

      - name: Download the release archive
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
        with:
          name: demo-app-${{ github.sha }}
          path: ${{ runner.temp }}/demo-app-release

      - name: Verify the downloaded release archive
        id: archive
        run: |
          archive_path="$RUNNER_TEMP/demo-app-release/demo-app-$GITHUB_SHA.tar.gz"
          test -s "$archive_path"

          archive_digest="$(sha256sum "$archive_path" | awk '{print $1}')"
          [[ "$archive_digest" =~ ^[0-9a-f]{64}$ ]]

          printf 'path=%s\n' "$archive_path" >> "$GITHUB_OUTPUT"
          printf 'digest=%s\n' "$archive_digest" >> "$GITHUB_OUTPUT"

      - name: Validate deployment connection settings
        run: |
          [[ "$DEPLOY_HOST" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
          [[ "$DEPLOY_PORT" =~ ^[0-9]{1,5}$ ]]
          (( DEPLOY_PORT >= 1 && DEPLOY_PORT <= 65535 ))
          [[ "$EXPECTED_HOST_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]

      - name: Verify the SSH host key and deploy
        env:
          DEPLOY_PRIVATE_KEY: ${{ secrets.DEMO_APP_DEPLOY_KEY }}
          ARCHIVE_PATH: ${{ steps.archive.outputs.path }}
          ARCHIVE_DIGEST: ${{ steps.archive.outputs.digest }}
        run: |
          ssh_dir="$RUNNER_TEMP/demo-app-ssh"
          private_key="$ssh_dir/deploy-key"
          scanned_keys="$ssh_dir/scanned-host-keys"
          known_hosts="$ssh_dir/known-hosts"
          candidate_key="$ssh_dir/candidate-host-key"

          [[ -n "$DEPLOY_PRIVATE_KEY" ]]
          [[ -f "$ARCHIVE_PATH" && -s "$ARCHIVE_PATH" ]]
          [[ "$ARCHIVE_DIGEST" =~ ^[0-9a-f]{64}$ ]]

          install -d -m 0700 "$ssh_dir"
          printf '%s\n' "$DEPLOY_PRIVATE_KEY" > "$private_key"
          chmod 0600 "$private_key"

          ssh-keyscan \
            -T 10 \
            -p "$DEPLOY_PORT" \
            -t ed25519 \
            "$DEPLOY_HOST" \
            > "$scanned_keys"

          [[ -s "$scanned_keys" ]]
          : > "$known_hosts"

          while IFS= read -r scanned_key; do
            [[ -n "$scanned_key" ]] || continue
            printf '%s\n' "$scanned_key" > "$candidate_key"

            candidate_fingerprint="$(
              ssh-keygen -lf "$candidate_key" -E sha256 | awk '{print $2}'
            )"

            if [[ "$candidate_fingerprint" == "$EXPECTED_HOST_FINGERPRINT" ]]; then
              printf '%s\n' "$scanned_key" >> "$known_hosts"
            fi
          done < "$scanned_keys"

          [[ -s "$known_hosts" ]]
          sort -u -o "$known_hosts" "$known_hosts"
          rm -f -- "$candidate_key"

          ssh \
            -i "$private_key" \
            -p "$DEPLOY_PORT" \
            -o BatchMode=yes \
            -o ConnectTimeout=10 \
            -o IdentitiesOnly=yes \
            -o ServerAliveCountMax=4 \
            -o ServerAliveInterval=15 \
            -o StrictHostKeyChecking=yes \
            -o UserKnownHostsFile="$known_hosts" \
            "deploy-demo@$DEPLOY_HOST" \
            "deploy $GITHUB_SHA $ARCHIVE_DIGEST $GITHUB_RUN_ID $GITHUB_RUN_ATTEMPT" \
            < "$ARCHIVE_PATH"

      - name: Remove temporary deployment credentials
        if: ${{ always() }}
        run: rm -rf -- "$RUNNER_TEMP/demo-app-ssh"

The action comments show the release tags represented by the pinned commit hashes. Before publishing or adopting the workflow, confirm each hash in the action's official repository. Renovate or Dependabot can propose action updates, but a maintainer should review the release notes and resulting full hash before merging.

The MySQL service uses the mysql:8.4 line so CI receives supported 8.4 patch updates. Teams that require byte-for-byte reproducibility should use a reviewed image digest such as mysql:8.4@sha256:<digest> and automate reviewed digest updates. Never invent or copy an old digest without confirming that it belongs to the intended official image and architecture.

Do not add a workflow-level cancel-in-progress: true rule that can terminate a running production deployment. The deploy job uses an environment-specific concurrency group and waits for an active deployment to finish. The receiver also uses a server-side lock, so a second connection cannot alter release state concurrently.

If the repository has no frontend, remove the frontend job, its entry in the aggregate job, the Node.js packaging steps, and the Vite manifest requirement in both the workflow and receiver. If the repository uses pnpm or Yarn, replace the Node setup with the project's real locked package-manager commands rather than generating a second lock file.

The package job runs only for a successful push to protected main. It has no production environment, deployment key, or runtime credential. The deploy job receives the protected environment only after packaging and does not execute Composer, npm, or project build scripts. It downloads the archive from the same workflow run, recalculates its digest, and binds it to GITHUB_SHA through the server-validated REVISION file. This job separation limits the opportunity for build-time code to observe a later deployment credential.

The sample retains the GitHub artifact for one day. Increase retention-days if production approval can legitimately take longer, but keep the window no longer than operations require. If the artifact expires, rerun the complete workflow so CI and packaging are recorded together rather than uploading a file manually.

The archive is still trusted application code, not an untrusted pull-request artifact. A higher-assurance pipeline can add GitHub artifact attestations or organization-approved signing, but verification must remain bound to the reviewed commit and trusted workflow identity.

If private Composer or npm packages require authentication, use a separate read-only package credential scoped to the packaging job. Inject it only into the install step through the package manager's supported environment mechanism. Do not write a token into a committed .npmrc, Composer auth.json, release archive, cache key, or build output. The sample archive and receiver reject common package-manager credential files as a final safeguard.

19. Perform the first deployment

Treat the first deployment as a controlled commissioning exercise. Schedule a quiet period, confirm that an engineer with server administration access is available, and take a database backup if the database already contains data.

19.1 Run the server preflight

Validate every configuration parser before the workflow is allowed to connect:

sudo nginx -t
sudo php-fpm8.3 -t
sudo sshd -t
sudo visudo -cf /etc/sudoers
sudo bash -n /usr/local/sbin/demo-app-deploy-release

Verify protected configuration and writable storage without displaying secrets:

sudo stat -c '%U:%G %a %n' \
  /etc/demo-app/app.env \
  /etc/demo-app/migrate.env

sudo -u deploy-demo test -r /etc/demo-app/app.env
sudo -u deploy-demo test -r /etc/demo-app/migrate.env
sudo -u demo-app test -w /var/www/app.example.com/shared/storage/logs

Verify the limited service permissions:

sudo -u deploy-demo sudo -n \
  /usr/bin/supervisorctl \
  -c /etc/supervisor/supervisord.conf \
  status demo-app-horizon

sudo -u deploy-demo sudo -n \
  /usr/bin/supervisorctl \
  -c /etc/supervisor/supervisord.conf \
  status demo-app-scheduler

Both programs should exist and may report STOPPED. An UNKNOWN or no such process result means Supervisor has not loaded the configuration.

Check the server time. Certificate validation, signed callbacks, log correlation, and token expiry depend on a synchronized clock:

timedatectl status
systemctl status systemd-timesyncd --no-pager

Confirm that the runtime database and Redis connections work using a safe application command after the first release is present. Before that release exists, use database and Redis client tools with interactive credential entry rather than placing passwords in command history.

19.2 Trigger and observe the first run

Merge the workflow and application changes to main. GitHub should run both CI jobs, produce the required aggregate status, and pause at the production environment if approval is required.

Before approving:

  1. Confirm the workflow commit is the intended commit.
  2. Review the CI results and any dependency audit findings.
  3. Confirm the backup and rollback plan.
  4. Confirm no unrelated maintenance is changing MySQL, Redis, Nginx, PHP-FPM, Supervisor, or DNS.

Approve the environment deployment. The receiver logs each major phase to the Actions job without printing secrets. A successful first run should show archive validation, migration, cache building, activation, process startup, local HTTPS checks, and completion.

19.3 Verify the deployed state

On the server, resolve the active release and compare it with the GitHub commit:

readlink -f /var/www/app.example.com/current
cat /var/www/app.example.com/current/REVISION

Test the origin directly while retaining the correct TLS hostname:

curl \
  --fail \
  --silent \
  --show-error \
  --resolve app.example.com:443:127.0.0.1 \
  https://app.example.com/up

curl \
  --fail \
  --silent \
  --show-error \
  --resolve app.example.com:443:127.0.0.1 \
  https://app.example.com/_health/release

Then test through the public route, including Cloudflare if it is enabled:

curl --fail --silent --show-error https://app.example.com/up

Check process and application state:

sudo supervisorctl status demo-app-horizon
sudo supervisorctl status demo-app-scheduler

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan horizon:status

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan schedule:list

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan migrate:status

Check that the persistent links resolve to shared paths:

readlink -f /var/www/app.example.com/current/.env
readlink -f /var/www/app.example.com/current/storage
readlink -f /var/www/app.example.com/current/public/storage

Review recent errors rather than assuming a 200 response proves every component works:

sudo tail -n 100 /var/log/nginx/app.example.com.error.log
sudo tail -n 100 /var/log/demo-app/php-fpm.log
sudo tail -n 100 /var/log/demo-app/horizon.log
sudo tail -n 100 /var/log/demo-app/scheduler.log
sudo tail -n 100 /var/www/app.example.com/shared/storage/logs/laravel.log

The Laravel log filename may include a date when the daily channel is used. Select the current file rather than assuming laravel.log exists.

Test one real, low-risk user journey, one queued job, one scheduled task, one outbound message in a non-chargeable test mode, and one upload if the application supports uploads. Infrastructure checks cannot replace an application-level smoke test.

19.4 Enable process startup after reboot

After the first release and smoke test succeed, change autostart=false to autostart=true in both Supervisor program files. Then reload the definitions:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status demo-app-horizon
sudo supervisorctl status demo-app-scheduler

supervisorctl update may recycle a program whose definition changed. Do this during the commissioning window and confirm both programs return to RUNNING.

Finally, perform a planned reboot test when the application can tolerate it. Verify that Nginx, PHP-FPM, Supervisor, Horizon, the scheduler, MySQL, and Redis return to their expected states. A service that works only until the next VPS restart is not fully deployed.

20. Understand routine deployments and recovery

After commissioning, an ordinary deployment should require no interactive server login:

  1. A developer opens a pull request.
  2. CI tests the merge candidate.
  3. Reviewers approve and merge it into main.
  4. The protected deploy job builds the exact pushed commit.
  5. An authorized reviewer approves production when required.
  6. The workflow confirms that the commit is still the tip of main.
  7. The receiver activates the release and runs health checks.
  8. Monitoring confirms user-facing behavior after deployment.

A workflow rerun uses the same GitHub run ID and a higher run-attempt value, so it receives a unique release directory. A newer commit on main causes an older waiting deployment to fail the freshness check instead of deploying stale code.

20.1 Read a failed deployment in the correct order

When a deployment fails, start with the GitHub job output and identify the last completed receiver phase. Then check the corresponding server log:

  • connection failure: firewall, SSH service, hostname, port, runner network path;
  • host fingerprint failure: expected fingerprint or a legitimate server host-key change;
  • archive rejection: workflow exclusions, size limits, unsupported symlinks, missing files;
  • migration failure: migration SQL, database privileges, locks, disk, or connection limits;
  • cache failure: invalid configuration, route serialization, view compilation, or permissions;
  • process reload failure: Horizon job duration, Supervisor state, Redis, or sudoers;
  • health failure: Nginx, PHP-FPM, Laravel boot, database, Redis, or the release route.

Do not rotate the host fingerprint merely to silence a mismatch. Confirm a legitimate host-key rotation through the VPS console or another trusted channel first.

20.2 Perform a manual code rollback

Use the automatic rollback first. If an operator must select an older successful release, verify the database remains compatible with that code before changing the symlink.

Each release contains its own Laravel configuration cache. If runtime secrets or endpoints changed after that release was created, its cached values may be stale even though .env is a shared symlink. Keep old and new credentials valid through the rollback window during planned rotations, then deploy again after removing the old credential. Do not blindly activate an older release after an incompatible secret change, and never edit a cached PHP file by hand.

List candidates and inspect their revisions:

find /var/www/app.example.com/releases \
  -mindepth 1 \
  -maxdepth 1 \
  -type d \
  -name '*-*-*' \
  -printf '%TY-%Tm-%Td %TH:%TM:%TS %f\n' | sort -r

for revision_file in /var/www/app.example.com/releases/*/REVISION; do
  printf '%s: ' "$(dirname "$revision_file")"
  cat "$revision_file"
done

Set the exact release directory name after reviewing it. The validation prevents path traversal and accidental selection outside releases:

target_release='REPLACE_WITH_COMMIT-RUN_ID-ATTEMPT'

[[ "$target_release" =~ ^([0-9a-f]{40}|[0-9a-f]{64})-[0-9]+-[0-9]+$ ]]
test -d "/var/www/app.example.com/releases/$target_release"
test -f "/var/www/app.example.com/releases/$target_release/.activated"

Activate it with a same-directory temporary symlink and one atomic rename:

sudo ln -s \
  "releases/$target_release" \
  /var/www/app.example.com/.current.manual.tmp

sudo mv -Tf \
  /var/www/app.example.com/.current.manual.tmp \
  /var/www/app.example.com/current

sudo supervisorctl restart demo-app-horizon
sudo supervisorctl restart demo-app-scheduler

Check the restored commit locally and publicly. Replace the expected revision with the content of that release's REVISION file:

curl \
  --fail \
  --silent \
  --show-error \
  --resolve app.example.com:443:127.0.0.1 \
  https://app.example.com/_health/release

curl --fail --silent --show-error https://app.example.com/up

Record the rollback, the reason, the selected release, database changes that had already occurred, and the follow-up owner. A manual production change should not disappear into shell history.

20.3 Roll forward after an incident

Once service is stable, fix the problem on a branch, run the normal review and CI path, and deploy a new release. Do not edit files under current or an older release. An edit inside an immutable release is hard to reproduce, is lost on the next deployment, and makes the recorded commit inaccurate.

If an emergency change truly cannot wait for the full path, use the organization's documented break-glass process, require a second reviewer when possible, record every command, and immediately reproduce the change in source control. The goal is to make exceptional access visible and temporary.

21. Design database changes for code rollback

Atomic code activation does not make a database migration atomic with the application release. The safest general pattern is expand, migrate data, switch behavior, and contract.

21.1 Expand

Add new tables, columns, or indexes while the old application is still running. New columns should normally be nullable or have a compatible default at this stage. Avoid renaming or dropping objects that the old code still uses.

Examples:

  • add normalized_phone while retaining phone;
  • add a new status value without removing old values;
  • create a replacement table without redirecting reads yet;
  • add an index before releasing the query that needs it.

21.2 Deploy compatible code

Deploy code that works with both the old and new schema. During a transition it may write both representations, read the new value with an old-value fallback, or use a feature flag to keep the new path disabled.

21.3 Backfill outside the request path

Move existing data in small, restartable batches. Track progress, make the operation idempotent, limit database load, and expose metrics. Large backfills should not run as one blocking release migration.

For a Laravel command, prefer ordered chunks over loading the entire table:

<?php

use App\Models\Contact;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Command\Command;

Artisan::command('contacts:backfill-normalized-phone', function (): int {
    Contact::query()
        ->whereNull('normalized_phone')
        ->orderBy('id')
        ->chunkById(500, function ($contacts): void {
            foreach ($contacts as $contact) {
                $contact->forceFill([
                    'normalized_phone' => normalize_phone($contact->phone),
                ])->saveQuietly();
            }
        });

    return Command::SUCCESS;
});

The normalization function and model are placeholders. Production backfills need tests, progress output, retry behavior, and a decision about timestamps, model events, observers, and concurrent writes.

21.4 Switch reads and enforce invariants

After the backfill is complete and verified, enable reads from the new representation. In a later deployment, make a column non-null or add a constraint if the application now guarantees it.

21.5 Contract later

Remove old writes first. Observe at least one safe rollback window. Only then remove the old column, table, index, route, event shape, or queue payload support in a separate release.

Large MySQL schema changes can wait on metadata locks, rebuild a table, increase replication lag, fill disk, or block application queries. Test the exact migration on a production-like copy with representative row counts. Check the MySQL version's online DDL behavior rather than assuming every ALTER TABLE is nonblocking.

The workflow's deployment timeout must exceed the longest approved migration and graceful worker shutdown. A timeout or lost SSH connection can interrupt the receiver, but it cannot undo a statement the database already committed. Move operations that cannot finish predictably within the deployment window into a separately monitored maintenance or online-schema-change procedure.

Before a destructive migration:

  • create and verify a recoverable backup;
  • estimate duration and additional disk use;
  • check long-running transactions and expected locks;
  • define an abort threshold;
  • define application compatibility during partial completion;
  • communicate the maintenance or risk window;
  • prepare a roll-forward recovery, not only a down migration.

Do not call migrate:fresh, db:wipe, or a destructive seeder in a production workflow. Do not assume DDL will participate in a transaction simply because migration code is wrapped in one.

Queue payloads also form a compatibility boundary. A job enqueued by release N may still be processed after release N+1 starts. Keep job classes and serialized fields backward compatible across the queue's maximum age, or drain and pause the queue under a planned maintenance procedure.

22. Build health checks that reflect readiness

Laravel's default /up route proves that the framework can boot and return a response. It does not automatically prove that MySQL, Redis, storage, a provider, or every queue is healthy.

Laravel dispatches DiagnosingHealth while handling the health route. Critical local dependencies can be checked in an application service provider:

<?php

namespace App\Providers;

use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(DiagnosingHealth::class, function (): void {
            DB::select('SELECT 1');
            Redis::connection()->ping();
        });
    }
}

Only add a dependency when the application should genuinely be considered unavailable without it. A slow third-party API should not usually sit in the synchronous readiness path. Check external providers through separate synthetic monitoring with strict timeouts and circuit-breaker behavior.

A useful health model has several layers:

Layer Example Consumer Failure meaning
Process PHP-FPM socket accepts work local service monitor PHP runtime unavailable
Readiness /up checks Laravel and critical stores deploy receiver and load balancer instance should not receive traffic
Release identity /_health/release returns REVISION deploy receiver wrong code is active
Uptime public /up over normal DNS external monitor public path is unavailable
Synthetic login or safe business transaction application monitor user journey is broken
Asynchronous queue age and scheduler heartbeat operations alerting background work is delayed or stopped

Health handlers must be fast, deterministic, safe to call repeatedly, and free of side effects. They should not disclose version details, secrets, stack traces, database names, or internal hostnames. Send detailed diagnostics to protected logs and metrics instead.

Set APP_DEBUG=false in production. A detailed exception page can expose configuration, file paths, query details, and request data precisely when a health check is failing.

23. Operate, monitor, and back up the application

Deployment automation is only one part of production reliability. The team also needs signals that reveal a failure, enough retained data to investigate it, and backups that can actually be restored.

23.1 Monitor the complete service

At minimum, collect and alert on the following:

Area Signals Example alert condition
Public availability HTTPS status, latency, TLS expiry repeated failure from multiple locations
Nginx request rate, 4xx, 5xx, upstream response time sustained 5xx increase
PHP-FPM active and idle workers, max-children reached, slow requests pool saturation or repeated worker exits
Host CPU, load, memory, swap, disk bytes, disk inodes low disk or sustained memory pressure
MySQL connections, query latency, lock waits, disk, replication lag connection exhaustion or lock spike
Redis memory, evictions, blocked clients, persistence status eviction or memory limit reached
Horizon process state, queue wait, throughput, failed jobs critical queue age exceeds objective
Scheduler heartbeat and last successful critical task heartbeat missing for two intervals
Backups completion, size, age, restore test last valid backup exceeds RPO
Deployments commit, duration, result, rollback failed deployment or unexpected duration
Business flow safe synthetic transaction request succeeds technically but business flow fails

Thresholds must reflect the application's service objectives and normal workload. A queue depth of 100 may be harmless for a nightly batch queue and unacceptable for a user-facing callback queue. Alert on queue age and processing delay, not only raw depth.

Useful local checks include:

systemctl --failed
df -h
df -i
free -h
uptime

sudo supervisorctl status
sudo ss -lntp

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan queue:failed

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan horizon:status

sudo -u demo-app /usr/bin/php8.3 \
  /var/www/app.example.com/current/artisan schedule:list

ss -lntp requires sufficient privileges to show every owning process. Review its output to confirm that MySQL, Redis, and PHP-FPM are not unexpectedly exposed on public interfaces.

Implement a scheduler heartbeat as a real scheduled task that writes a timestamp to monitoring or a dedicated datastore after the scheduler runs successfully. Checking only that schedule:work has a process ID does not prove scheduled tasks are completing.

23.2 Keep logs useful and safe

Use structured logs with request or correlation identifiers. Include the release commit in error events so an incident can be tied to a deployment. Log provider event IDs and internal record IDs where useful, but do not log:

  • passwords, access tokens, private keys, or signing secrets;
  • full session cookies or authorization headers;
  • complete payment or identity payloads;
  • message contents unless policy explicitly permits them;
  • raw database connection strings;
  • sensitive personal data that is not required for diagnosis.

Set retention based on incident response, legal, privacy, and cost requirements. Confirm both Laravel-managed and system-managed logs rotate. A full filesystem can stop MySQL, Redis persistence, PHP sessions, uploads, and deployments at the same time.

If logs are forwarded, use an authenticated encrypted channel and restrict who can search them. Redaction should occur before sensitive values leave the application whenever possible.

23.3 Define backup objectives before choosing a tool

Record two numbers:

  • Recovery point objective (RPO): the maximum acceptable amount of lost data, measured in time.
  • Recovery time objective (RTO): the maximum acceptable time to restore service.

A nightly dump cannot meet a five-minute RPO. A provider snapshot that takes hours to copy into a new server cannot meet a fifteen-minute RTO. Choose database backups, binary logs, snapshots, replicas, and object versioning based on those requirements.

A complete backup plan normally includes:

  1. MySQL data, with point-in-time recovery when the RPO requires it.
  2. Persistent uploads under shared storage, or the object storage bucket that replaces them.
  3. Encryption keys and production configuration through an approved encrypted secret backup.
  4. Nginx, PHP-FPM, Supervisor, SSH, sudoers, and firewall configuration in reviewed infrastructure code or a protected configuration backup.
  5. DNS, proxy, mail, queue, and provider configuration that is needed to rebuild the service.

Git history and retained releases do not back up MySQL, user files, or secrets. A VPS snapshot stored only in the same provider account is also vulnerable to account compromise and provider-level failure.

For a manual logical MySQL backup, use a dedicated backup identity and an interactive password prompt. Review engine, consistency, encryption, and storage requirements before treating this example as an automated backup system:

umask 077
set -o pipefail

mysqldump \
  --host=127.0.0.1 \
  --user=demo_app_backup \
  --password \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --events \
  --no-tablespaces \
  --set-gtid-purged=OFF \
  demo_app | gzip > demo_app-backup.sql.gz

test -s demo_app-backup.sql.gz
gzip --test demo_app-backup.sql.gz

The backup account needs only the privileges required by the selected backup method. Avoid placing its password on the command line. For scheduled backups, use the platform's secret facility or a root-owned MySQL option file with mode 0600, not a world-readable script.

The resulting dump contains sensitive production data and is only gzip-compressed, not encrypted. Encrypt it and transfer it to approved backup storage immediately, then remove the local staging copy according to a documented retention procedure.

Send backups to encrypted off-host storage with retention and deletion protection appropriate to the threat model. Monitor backup age and size. A suddenly tiny backup can be a failure even when the command exits successfully.

23.4 Test restoration

A backup is unproven until it has been restored into an isolated environment and validated. Run scheduled restore drills that answer these questions:

  • Can the team obtain the decryption keys and backup files?
  • Does MySQL restore without missing objects or privilege errors?
  • Are uploads and database records from a compatible point in time?
  • Can the application boot against the restored data?
  • Are row counts, critical aggregates, and sample user journeys correct?
  • How long does the full process take?
  • Can DNS or traffic be moved to the recovered service safely?

Never run a restore drill over production. Use an isolated database, isolated storage, non-production provider credentials, and controls that prevent messages or callbacks from reaching real users.

Redis queues deserve an explicit durability decision. If loss of queued work is unacceptable, configure Redis persistence, backups, and infrastructure accordingly, and make jobs recoverable from the source-of-truth database or provider event log. Redis cache data should normally be disposable. Do not assume queue durability because Redis and cache happen to share a server.

24. Harden the system beyond the deployment script

The receiver narrows one path into production. It does not replace host, application, account, or supply-chain security.

24.1 Host and network controls

  • Apply supported operating-system and security updates on a defined schedule.
  • Reboot for kernel and critical library updates through a tested maintenance process.
  • Permit SSH administration only from trusted networks or a managed access layer.
  • Disable password authentication and direct root login only after verified key-based recovery access exists.
  • Keep the VPS provider console and account recovery protected with strong multi-factor authentication.
  • Bind MySQL and Redis to private or loopback interfaces unless a documented private network requires otherwise.
  • Deny public database and Redis ports at both the host and provider firewall.
  • Remove unused packages, services, DNS records, SSH keys, and user accounts.
  • Monitor authentication failures, but do not treat an automated blocking tool as a substitute for key-only SSH and network controls.
  • Test low-disk behavior and reserve enough space for releases, logs, database growth, temporary files, and backup creation.

24.2 Laravel and application controls

  • Keep APP_ENV=production and APP_DEBUG=false.
  • Validate and authorize every request, including internal administrative endpoints.
  • Keep private uploads outside public; serve them through an authorized controller or short-lived object-storage URL.
  • Treat uploaded filenames and MIME declarations as untrusted. Generate server-side names and inspect content as required by the risk model.
  • Escape browser output and use framework query binding instead of string-built SQL.
  • Rate-limit login, password reset, verification, callback, search, and expensive export endpoints based on their abuse profile.
  • Use secure, HTTP-only cookies and the correct same-site policy for the application's authentication flow.
  • Set a tested Content Security Policy and monitor reports before enforcing it.
  • Use unique secrets per environment and provider integration.
  • Rotate credentials through a planned overlap when the provider supports multiple active keys.
  • Do not call env() outside configuration files when configuration caching is enabled.
  • Review authorization for queue-triggered and scheduled actions, not only web controllers.

24.3 Repository and CI controls

  • Review changes to workflows, dependency files, Composer scripts, npm scripts, Dockerfiles, and deployment code as privileged changes.
  • Pin actions to complete commit hashes and update them through reviewed pull requests.
  • Keep workflow permissions read-only by default.
  • Never use pull_request_target to check out and execute untrusted pull-request code with repository or environment secrets.
  • Do not run untrusted fork code on a persistent self-hosted runner that has production network access.
  • Enable dependency update tooling, secret scanning, dependency review, and code scanning where appropriate.
  • Verify that generated build files do not contain environment secrets.
  • Protect release approvals and branch rules from a single compromised developer account where the team size permits separation.

24.4 Secret lifecycle and incident readiness

Maintain an inventory of every credential, its owner, purpose, scope, storage location, creation date, and rotation procedure. Include:

  • Laravel APP_KEY;
  • runtime and migration database passwords;
  • Redis credentials;
  • mail and messaging credentials;
  • callback signing secrets;
  • object-storage keys;
  • GitHub deployment key;
  • VPS administration and host keys;
  • Cloudflare or DNS API tokens.

Changing APP_KEY is not a routine rotation. It affects encrypted cookies and application data. Use Laravel's supported previous-key mechanism and test every encrypted data path before a key migration.

Prepare an incident runbook that can revoke the deployment key, disable a provider credential, block a route, place the application into maintenance mode, preserve logs, restore data, and communicate status. Test the runbook before an incident.

25. Adapt the pattern for callbacks, webhooks, SMS, or USSD

Applications that receive provider callbacks have extra availability and security requirements. The deployment architecture remains the same, but the request path needs deliberate design.

25.1 Separate transport acknowledgement from processing

For a webhook that permits asynchronous handling:

  1. Read the raw request body.
  2. Verify the provider's signature and timestamp exactly as documented.
  3. Reject missing, expired, malformed, or invalid signatures.
  4. Derive a stable provider event ID.
  5. Insert the event under a unique database constraint.
  6. Dispatch processing after the transaction commits.
  7. Return the provider's required success response quickly.

An IP allowlist can be a useful additional control, but it is not a substitute for signature verification unless the provider offers no stronger mechanism. Proxy configuration must preserve the real peer address before an allowlist is meaningful.

For Laravel applications that place a callback under the web middleware group, exclude only the exact callback path from CSRF validation. Do not disable CSRF protection for the whole application. Merge this call into the existing Application::configure() chain in bootstrap/app.php:

->withMiddleware(function (
    \Illuminate\Foundation\Configuration\Middleware $middleware
): void {
    $middleware->validateCsrfTokens(except: [
        'webhooks/provider',
    ]);
})

If the route is in a stateless API route group that does not use CSRF middleware, no exemption is needed. Signature verification, authorization, schema validation, rate limits, and idempotency are still required.

Enforce idempotency at the database layer. An application-only existence check has a race condition:

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::create('provider_events', function (Blueprint $table): void {
    $table->id();
    $table->string('provider');
    $table->string('provider_event_id');
    $table->string('type');
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();

    $table->unique(['provider', 'provider_event_id']);
});

Decide whether retaining the entire payload is permitted. Redact, encrypt, or omit sensitive fields based on business need and data policy.

USSD and some messaging gateways require a synchronous response within a short deadline. In that case, validate the request, read or write only the state required for the immediate response, return the exact provider format, and queue slow side effects. Do not queue the response itself when the provider is waiting on the same HTTP request.

Test callback behavior during each of these conditions:

  • duplicate delivery;
  • delivery out of order;
  • invalid signature;
  • timestamp outside the replay window;
  • provider retry after a timeout;
  • database commit followed by queue dispatch failure;
  • deployment while an old queued payload is still waiting;
  • rollback after a new event shape has been stored;
  • temporary provider outage;
  • application maintenance mode.

Laravel's after_commit queue setting helps prevent a worker from seeing database work that has not committed. It does not eliminate every delivery race. The handler and job must remain idempotent.

25.2 Keep provider credentials out of builds

Provider secrets belong in the root-owned runtime environment file or an approved runtime secret service. Frontend builds, pull-request jobs, logs, release archives, REVISION, health endpoints, and GitHub artifacts must not contain them.

When a provider requires source-IP registration, register the stable egress address used by the production application, not the changing addresses of GitHub-hosted runners. Deployment traffic and application provider traffic are separate network paths.

26. Troubleshoot common failures

Use the error message and the last successful phase to narrow the investigation. Avoid making several speculative changes at once because that destroys evidence about the actual cause.

Symptom Likely cause What to verify
CI does not run for a merge queue merge_group trigger is missing workflow triggers and required-check name
Deploy job is skipped event is not a push to main, CI failed, or environment rule blocked it job if, needs, branch, environment history
Latest-main check fails a newer commit reached main while approval was waiting deploy the newer successful run
GitHub API returns 403 contents: read is missing or repository policy blocks the token workflow permissions and Actions policy
SSH times out firewall, provider firewall, wrong address, wrong port, or SSH service failure VPS_HOST, VPS_SSH_PORT, network path, sshd
SSH host fingerprint fails wrong trusted value, changed host key, or network interception fingerprint through provider console or trusted admin session
Invalid deployment command manual SSH test or workflow command format differs from the receiver contract exact five command fields after deploy
Permission denied (publickey) wrong private key, malformed secret, wrong account, or authorized-key permissions public-key match, secret newlines, .ssh ownership and modes
Receiver says another deployment is running an active receiver holds .deploy.lock active SSH and deployment processes before taking any action
Archive is empty or digest differs stream interruption, wrong archive path, or content changed after hashing archive step outputs and SSH input redirection
Archive exceeds limit dependencies or build output are unexpectedly large archive contents, vendor, source maps, accidental data files
Archive path is blocked .env, storage, tests, Git metadata, or another excluded path entered the archive tar exclusions and generated files
Unsupported archive member repository or dependency contains a symbolic link, hard link, device, or FIFO inspect tar -tvzf output and remove or explicitly redesign for the link
Vite manifest is missing frontend build failed or uses a different output path Vite configuration and public/build/manifest.json
Composer platform check fails runner PHP or extensions do not match locked requirements composer.lock, ext-* requirements, setup-php extensions
Shared env permission check fails owner, group, mode, or symlink differs from the receiver contract stat -c '%U:%G:%a %N' without printing contents
Migration gets access denied host component, password, grants, or database name is wrong migration account through an interactive client and SHOW GRANTS
Migration waits indefinitely metadata lock or long transaction MySQL process and transaction views, application traffic
artisan optimize fails invalid env, cached route issue, view error, or unwritable cache run the exact command as deploy-demo in the failed release before it is cleaned up
Nginx returns 502 PHP-FPM is stopped, socket path differs, or Nginx lacks socket access FPM status, pool config, socket owner/group/mode, Nginx error log
Nginx returns 403 parent directory cannot be traversed or files cannot be read namei -l on current/public/index.php, service groups, modes
/up returns 500 Laravel boot or a readiness dependency failed Laravel and PHP-FPM logs, MySQL, Redis, config cache
Release route returns 403 publicly expected behavior route is intentionally restricted to loopback
Release route returns old commit locally wrong symlink, PHP path handling, or failed activation readlink -f current, REVISION, Nginx $realpath_root settings
Cloudflare returns 525 or 526 origin TLS handshake or certificate validation failed origin certificate, hostname, expiry, Full (strict) settings
Real client IP is wrong missing or stale trusted Cloudflare networks real-IP snippet, official address list, direct-origin exposure
Horizon reload times out a long job is still running, job ignores timeouts, or Redis is unavailable Horizon log, job runtime, stopwaitsecs, queue timeout and retry_after
Jobs execute twice timeout is not below retry_after, duplicate delivery, or job is not idempotent queue config, provider event ID, database unique constraint
Scheduler runs tasks twice both cron and schedule:work are active, or several hosts schedule the same task process list, crontabs, Supervisor, single-server controls
Deployment succeeds but users see old assets CDN or browser cache retained a non-hashed asset asset URLs, cache headers, CDN purge policy, Vite hashing
Certificate renewal simulation fails challenge path, DNS, firewall, or proxy rule changed Certbot output, port 80, ACME location, DNS records
Server becomes unstable during deploy disk, memory, process count, or database lock pressure host metrics, archive size, Composer build location, migration plan

Useful diagnostic commands are:

sudo systemctl status nginx php8.3-fpm supervisor ssh --no-pager
sudo journalctl -u nginx -u php8.3-fpm -u supervisor -u ssh --since '-30 minutes'

sudo nginx -t
sudo php-fpm8.3 -t
sudo sshd -t
sudo visudo -cf /etc/sudoers

sudo supervisorctl status
sudo supervisorctl tail -100 demo-app-horizon
sudo supervisorctl tail -100 demo-app-scheduler

namei -l /var/www/app.example.com/current/public/index.php
ls -la /run/php/php8.3-fpm-demo-app.sock

sudo certbot certificates
sudo certbot renew --dry-run --run-deploy-hooks

Be careful when copying diagnostic output into an issue or chat. Process environments, expanded Nginx configuration, Laravel exception output, database connection errors, and command histories can contain secrets or internal addresses. Redact locally before sharing.

If an archive fails after it has been moved into a release directory, the receiver's exit handler normally removes it. To preserve a failed release for diagnosis, add an explicit, reviewed diagnostic mode rather than commenting out cleanup directly on production during an incident.

27. Customize the guide for a real project

Replace every example consistently. A partial rename is one of the easiest ways to create a deployment that controls the wrong service or reads the wrong file.

Example Replace with Places to update
app.example.com production hostname app root, Nginx, TLS paths, receiver, health checks, APP_URL
demo-app safe application slug group, service user, FPM pool, Supervisor programs, logs, workflow concurrency
deploy-demo deployment user directories, sudoers, SSH, receiver permission checks, workflow SSH target
example-org/demo-app repository documentation and operational inventory
PHP 8.3 supported project version runner, binary, FPM paths, service, socket, Supervisor, diagnostics
Node.js 22 locked build version CI and deploy jobs, project version file
main protected production branch triggers, environment rule, latest-commit API path
MySQL 8.4 supported database CI image, production provisioning, migration testing
queue names and limits measured workload settings Horizon, queue config, env, alerts, stopwaitsecs
512 MiB archive limit reviewed maximum receiver only, with capacity and denial-of-service implications
five releases retention policy receiver, disk alerts, recovery procedure

Use a slug containing lowercase letters, digits, and hyphens for service and program names. Linux account naming rules may be stricter, so confirm names before provisioning.

Search the finished configuration for leftovers:

rg -n \
  'app\.example\.com|demo-app|deploy-demo|php8\.5|PHP 8\.5|Node\.js 22' \
  .github config routes

Run a corresponding search through the server configuration from an administrative session. Do not copy private keys or .env contents into search output that will be shared.

27.1 Applications without local uploads

Keep shared storage because Laravel still uses it for logs, cache files, sessions, compiled views, and framework state. If all user media lives in object storage, the storage/app/public link may be unnecessary, but remove it only after reviewing every configured filesystem disk.

27.2 Applications without Horizon

Remove the Horizon Supervisor program, Horizon sudoers entries, reload_horizon, Horizon Redis configuration, and Horizon checks. Replace them with standard queue:work Supervisor programs if the application still uses queues. Laravel queue workers are long-running and still need a restart after deployment.

27.3 Applications using Octane, Reverb, or other daemons

Create separate service accounts or Supervisor/systemd units as appropriate, grant the receiver only exact restart commands, and add application-specific readiness checks. Long-running processes retain old code and container state until reloaded. Follow the official lifecycle command for each service rather than assuming a PHP-FPM cache clear is enough.

27.4 Multiple application servers

The single-VPS receiver is not a complete multi-server deployment coordinator. A multi-server design must also address:

  • one immutable artifact promoted to every host;
  • database migrations executed once under a distributed deployment lock;
  • load-balancer draining and per-host readiness;
  • shared sessions, cache, queues, and object storage;
  • coordinated worker reloads;
  • mixed-version compatibility during a rolling rollout;
  • host failure partway through deployment;
  • fleet-wide rollback state and audit history.

Do not run the same automatic migration independently on every server. At that scale, use a deployment platform or an orchestrator with explicit rollout state rather than extending the shell receiver casually.

27.5 Container deployments

The same principles still apply: build once, identify the artifact, keep secrets at runtime, run migrations as a controlled one-off task, update services gradually, verify readiness, retain a known-good image, and keep schema changes backward compatible. The implementation should use the container platform's native image registry, service identity, secret store, health probes, and rollout controller instead of copying this VPS filesystem layout into a container.

28. Production readiness checklist

Use this checklist for the first launch and repeat the relevant sections after major infrastructure changes.

Repository and application

  • composer.lock and the chosen Node lock file are committed.
  • Local installation works from the lock files on supported runtime versions.
  • Backend, frontend, integration, and failure-path tests pass.
  • .env.example contains no real credentials.
  • APP_DEBUG=false is set in production.
  • No production secret is exposed through a Vite-prefixed variable.
  • /up reflects the intended readiness definition.
  • /_health/release returns REVISION and is local-only.
  • Uploaded and generated files that must persist live outside releases.
  • Queue jobs are idempotent and compatible across at least one release boundary.
  • Queue timeouts are shorter than their matching retry_after values.
  • Scheduled tasks are safe if retried and protected from unintended duplication.
  • Database migrations follow an expand-and-contract plan.

GitHub

  • main is protected by a ruleset or branch protection.
  • The aggregate CI job is a required check.
  • Workflow changes require review by an appropriate owner.
  • The production environment limits the deployment branch.
  • Required production reviewers are configured where available.
  • GITHUB_TOKEN defaults to read-only.
  • Every third-party action is pinned to a reviewed full commit hash.
  • The deployment private key exists only at the narrowest required secret scope.
  • Host, port, and trusted fingerprint variables are correct.
  • Concurrency permits only one production deployment at a time and does not cancel an active deployment.

Server and network

  • The operating system and packages are supported and patched.
  • Administrative recovery access is tested before SSH restrictions change.
  • The deployment user cannot open a shell with its workflow key.
  • The receiver is root-owned, mode 0755, and passes bash -n.
  • Sudoers permits only the named Supervisor commands.
  • Nginx serves only public and passes only index.php to PHP-FPM.
  • The dedicated PHP-FPM pool runs as the service account.
  • MySQL and Redis are not exposed to the public internet.
  • The host and provider firewalls implement the intended port policy.
  • DNS has no stale or incorrect AAAA record.
  • Origin HTTPS works with a trusted certificate.
  • Certbot renewal passes a dry run.
  • Cloudflare uses Full (strict) if proxying is enabled.
  • Real-IP trust is limited to current Cloudflare proxy networks.
  • Host time synchronization is healthy.
  • Disk-byte and inode alerts leave enough response time.

Data and secrets

  • Runtime and migration database users have separate scoped privileges.
  • Runtime .env is root-owned and not writable by application processes.
  • Migration credentials are root-owned and parsed without shell execution.
  • Redis isolation, authentication, persistence, and eviction policy are documented.
  • Secret ownership and rotation procedures are documented.
  • Backups cover the database, persistent files, critical configuration, and required keys.
  • Backups are encrypted, off-host, monitored, and retained appropriately.
  • A recent restore drill met the documented RPO and RTO.

Processes and observability

  • Horizon and scheduler program definitions load under Supervisor.
  • stopwaitsecs exceeds the longest expected job duration.
  • Process startup after reboot has been tested.
  • External uptime monitoring follows normal public DNS.
  • Queue age, failed jobs, scheduler heartbeat, and backup age are monitored.
  • Nginx, PHP-FPM, MySQL, Redis, and host capacity metrics are monitored.
  • Logs rotate, redact sensitive values, and have documented retention.
  • Deployment success and failure reach the responsible team.
  • An incident and manual rollback runbook exists and has been rehearsed.

First deployment

  • Configuration parsers and permission preflights pass.
  • A current backup exists before migrating existing data.
  • The workflow deploys the intended commit and records its revision.
  • The active symlink points to the expected release.
  • Local origin and public health checks pass.
  • Horizon, scheduler, migrations, and persistent links are correct.
  • A low-risk user journey and asynchronous path pass.
  • No unexpected errors appear in application or system logs.
  • Supervisor autostart is enabled after the first release exists.
  • A planned reboot confirms service recovery.

29. Official references

These references were checked on 21 September 2026. Confirm version-specific details again when adopting the guide because frameworks, actions, operating systems, and provider behavior change.

Laravel

GitHub Actions and repository controls

Server, TLS, proxy, database, and Redis

30. Closing principle

A dependable deployment is a controlled state transition, not a file-copy command. The code must be identifiable, the artifact must be verified, secrets must remain outside it, activation must be atomic, database changes must preserve compatibility, long-running processes must load the new release, health checks must prove the intended state, and recovery must be practiced before it is urgent.

Once this foundation is in place, routine releases become intentionally boring. That is the right outcome for production deployment work.

A practical next step

Need help applying this to a real system?

Statum can help turn the idea into a scoped piece of work. See our software services, browse completed projects, read the developer documentation, or start a project conversation.

B

Bob Mwenda

Article author

Bob Mwenda is a software engineer at Statum with over ten years of experience building business applications and integrating enterprise systems. His work spans Java, Spring Boot, PHP, Laravel and Vue.js, including API integrations, payment and messaging systems, and application deployment. On the Statum blog, Bob writes about software development, business automation and the practical decisions behind building and maintaining software. His articles help business owners and developers understand implementation options, technical trade-offs and common pitfalls.

More Articles

View All →