The operator's runbook, not a click-through

20% Shipped A green deploy. Twenty minutes of dashboard clicks.
80% Ahead of you Atomic releases, queues, migration safety, tuning, hardening.

Deploy Laravel app on DigitalOcean with Laravel Forge: the production 80 percent

The atomic deploy is the easy 20 percent. Here is the production 80 percent: droplet sizing, queue workers, migration safety, tuning, and hardening every Forge tutorial stops before.

The atomic deploy is the easy 20 percent#

Reach for Forge and the first deploy feels like magic. Connect a DigitalOcean provider, provision a droplet, link a Git repository, and press deploy. In practice, twenty minutes later the site is green and serving traffic. That part is genuinely quick, and this guide will not pad it out.

Because the happy path is short, most tutorials end there. However, a green deploy is not a production system. The work that keeps a Laravel app up starts after the first deploy succeeds. Moreover, the reason teams choose this stack, over a managed platform, is control, and control means owning the operations. For the trade-off behind the framework itself, see why teams choose Laravel over WordPress for this kind of app.

What every guide to deploy Laravel app on DigitalOcean with Laravel Forge skips#

The gap is not subtle. Line up the production concerns against what a click-through actually covers, and the missing 80 percent is obvious. Each row below maps a skipped concern to the concrete command or config that closes it, all of which this runbook works through.

What a Forge click-through covers, and the production concern it leaves to you
Production concernWhat the tutorials showWhat this runbook gives you
Zero-downtime deployA single "deploy" buttonThe releases/ + current-symlink atomic model, dissected
The deploy scriptThe default, unexplainedEvery line, its ordering, and its failure mode
Queue workers + schedulerSkipped entirelyThe Supervisor program and the schedule:run cron
Migration safetymigrate --force, no contextBackward-compatible migrations + a tested rollback
Droplet sizingPick a size by feelpm.max_children and worker count from RAM arithmetic
PHP-FPM + opcache tuningThe stock configpm settings, opcache, and realpath cache from the math
Server hardening"SSL installed, done"Firewall, key-only SSH, database off the public interface
Backups + monitoringNot mentionedA scheduled dump with a rehearsed restore + a health route

Provision the droplet: sizing math, not a default click#

In practice, sizing is the first place a runbook and a tutorial part ways. A tutorial picks a droplet by vibe. Instead, derive it from arithmetic on the live DigitalOcean pricing, because the wrong size fails in production, not in the demo.

The rungs, and why the $12 2 GB tier OOMs a single-server app#

The live 2026 DigitalOcean droplet pricing gives clean rungs. They run $4 for 512 MiB, $6 for 1 GB, $12 for 2 GB, $18 for 2 GB with 2 vCPU, $24 for 4 GB with 2 vCPU, $48 for 8 GB with 4 vCPU, and $96 for 16 GB with 8 vCPU. The $12 tier looks like the sensible starter. However, a single-server Laravel app runs the code, MySQL, Redis, and a queue worker on one box.

At 2 GB those four pieces contend for memory. Under real load the kernel runs out and kills a process, and the box OOMs. Therefore the honest starting point for a single-server production app is the $24 4 GB / 2 vCPU droplet. It gives 4 GB RAM, 2 vCPUs, 80 GB SSD, and 4,000 GiB transfer. The next step up, when traffic grows, is the $48 8 GB / 4 vCPU rung.

The arithmetic: RAM to pm.max_children, opcache, and worker count#

A live formula is worth more than a fixed recommendation, because your app is not the worked example. Pick a rung or set RAM and vCPU below, choose your app's memory profile, and watch the pool settings recompute. The calculator subtracts the OS, database, and worker memory from total RAM, then divides the rest by the memory one PHP-FPM child uses.

Droplet sizing calculator

Pick a droplet, or set RAM and vCPU below

Live 2026 DigitalOcean Basic rungs.

App memory per PHP-FPM child
A lean API sits near 30 MB per request. A typical Laravel app with an admin and packages sits near 45 MB. A heavy app with image work or big payloads reaches 60 MB or more. Measure yours before you trust it.
pm.max_children442009 MB for PHP-FPM / 45 MB each
queue workers2one per vCPU on a shared box
opcache MB128opcache.memory_consumption
start / spare11start 11, min 9, max 27 spare
Comfortable4 GB leaves headroom for MySQL, Redis, 2 queue workers, and 44 PHP-FPM children. That is a sound single-server base. Move up a rung when sustained load pushes it.
Worked example, the $24 4 GB / 2 vCPU droplet at 45 MB per child
RAM / vCPU4096 MiB / 2 vCPU
reserved (OS + Redis)768 MB
MySQL footprintround( 0.30 x 4096 ) = 1229 MB
queue workersmax( 1, 2 vCPU ) = 2 (90 MB)
PHP-FPM pool RAM4096 - 768 - 1229 - 90 = 2009 MB
pm.max_childrenfloor( 2009 / 45 ) = 44
opcache.memory_consumptionclamp( round( 4096 / 32 ), 64, 256 ) = 128 MB

The sizing rule: subtract the OS, database, and queue-worker memory from total RAM, then divide the rest by the memory one PHP-FPM child uses. That quotient is pm.max_children. Below 4 GB the pieces do not fit, so a single-server app OOMs under load.

Live sizing, driven only by clicks and keyboard input. Pick a droplet or set RAM and vCPU, choose an app memory profile, and the pool settings, opcache size, and fit verdict recompute. All numbers are illustrative teaching arithmetic on the live 2026 pricing, not a benchmark. With JavaScript off, the worked-example table and the sizing rule still read below.

Notice the fit verdict flip as you drop below 4 GB. That is the OOM line the previous section named, made operable. Consequently the droplet choice stops being a guess and becomes a number you can defend.

This is the genuinely quick part, so it stays short. Connect your DigitalOcean provider in Forge, provision the sized droplet, then attach the repository and the branch you deploy from. Forge installs PHP, Nginx, MySQL, and Redis for you.

The deploy script, dissected line by line#

No ranking page dissects the deploy script or explains the atomic model. Yet the script is where a deploy either stays safe or serves broken code to a live visitor. So this is the core teach.

In-place git-pull vs zero-downtime atomic, side by side#

There are two shapes of deploy script. The naive one pulls into the live directory. The safe one builds a fresh release and flips a symlink. Switch between them below and watch where the risk lives.

bash
# In-place deploy. The directory the live site serves IS the working copy.
cd /home/forge/app
git pull origin main
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
npm ci && npm run build
php artisan migrate --force
php artisan optimize
php artisan queue:restart
# Between "git pull" and "optimize" the live site serves half-old, half-new
# code. A visitor in that window can hit a class that no longer exists.

The difference is the window. In the in-place script, the live site serves a half-updated directory while composer and the build run. Meanwhile the atomic script does all that work off to the side, then switches the site over in one step. A clean Git history makes either script easier to reason about, and a branching workflow that pairs cleanly with Forge's deploy hooks keeps the branch you deploy from honest.

In short, the atomic model is one idea. Each deploy builds into its own timestamped directory under releases/. Only when the build and migrations succeed does Forge flip the current symlink to the new release. Because that flip is a single filesystem operation, there is no moment when the site serves a partly built release. The Forge deployments documentation describes the macros that wrap it.

Forge's zero-downtime atomic deploy lifecycleA deploy builds into a fresh releases/TIMESTAMP directory. Composer, the asset build, and migrations run there, off the live path. Only if every step succeeds does Forge flip the current symlink to the new release atomically, then restart queue workers and prune old releases. If any step fails, current still points at the old release and the live site never moved.

Ordering that actually matters, and each failure mode#

The line order is not decorative. Each step guards the one after it, so a wrong order is a real failure. Below are the two that bite most often.

Shared paths and release retention#

Two details make the atomic model survive real use. First, shared paths like storage/ are symlinked from a single shared directory into every release, so user uploads persist across deploys. Second, old release directories are pruned past a retention count, so disk does not grow without bound.

/home/forge/app tree · bash
# Forge links shared paths from a shared/ dir into every new release, so user
# uploads and the .env survive a deploy. storage/ is the common case.
/home/forge/app
|-- current -> releases/20260718T1230
|-- releases/
|   |-- 20260716T0910
|   |-- 20260717T1432
|   `-- 20260718T1230
|       `-- storage -> /home/forge/app/shared/storage
`-- shared/
    |-- .env
    `-- storage/            # uploads live here, once, across every release
# Old releases are pruned past the retention count, so disk does not grow forever.

Day-2 operations the tutorials never reach#

Queues and the scheduler are where the click-throughs go silent. Yet a real Laravel app leans on both. So this section is pure differentiation from the happy path.

Queue workers under Supervisor and Horizon#

A queue worker is a long-lived PHP process, so it needs a supervisor to keep it alive. Forge writes a Supervisor program for you from the site's Queue tab. Notice that numprocs is the queue-worker count from the sizing calculator, which is 2 on the $24 droplet. The Forge queues documentation covers the dashboard side.

/etc/supervisor/conf.d/app-worker.conf · ini
; /etc/supervisor/conf.d/app-worker.conf
; Forge writes this from the site's Queue tab. numprocs is the queue-worker
; count from the sizing calculator: 2 on the $24 droplet.
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/app/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
directory=/home/forge/app/current
autostart=true
autorestart=true
stopwaitsecs=3600
user=forge
numprocs=2
redirect_stderr=true
stdout_logfile=/home/forge/app/storage/logs/worker.log

For the reliability side of workers, retries, and idempotency, our guide to running Laravel queues reliably at production scale goes deep on the model. Here the point is narrower: the worker count is a derived number, and Supervisor is what turns it into processes.

The scheduler cron: schedule:run every minute#

Laravel's scheduler needs exactly one cron entry on the server. That single line calls schedule:run every minute, and Laravel's own code decides what actually fires. The click-throughs never mention it, so scheduled jobs silently never run.

crontab -e (forge user) · bash
# crontab -e for the forge user. This one line runs the Laravel scheduler.
# Laravel's own schedule() method decides what actually fires each minute.
* * * * * php /home/forge/app/current/artisan schedule:run >> /dev/null 2>&1

It belongs on the server, not in the app, because only the operating system's cron ticks reliably every minute. Therefore this one line is the difference between a working scheduler and a queue of jobs that never dispatch.

queue:restart on every deploy, or workers run stale code#

Migration safety and a real rollback runbook#

Production migration safety is absent from the ranking set. A concrete recovery path is too. Both matter more than any tuning flag, because a bad migration is the one failure a symlink flip cannot undo.

Guarding destructive and irreversible migrations#

Some migrations cannot reverse. A dropped column takes its data with it, so the down() method can restore the shape but never the values. Therefore treat any drop or rename as a two-deploy change. First stop writing the column, then drop it a release later, once no running code depends on it.

database/migrations/drop_legacy_status.php · php
// A migration that DROPS a column cannot auto-reverse to the old data.
// Split it across two deploys, so an atomic rollback stays safe.
// Deploy 1: stop writing the column in app code, but keep it in the schema.
// Deploy 2, a release later: drop it, once no running code reads it.
public function up(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->dropColumn('legacy_status'); // irreversible: the data is gone
    });
}

public function down(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->string('legacy_status')->nullable(); // shape back, DATA does not
    });
}

Back up before you migrate, and test the restore#

The rule is short. Take a database backup before the migrate step, on every deploy. Then, and this is the part teams skip, restore that backup into a scratch database and confirm it works.

pre-deploy backup · bash
# Back up the database BEFORE the migrate step, on every deploy.
# An untested backup is not a backup, so restore it into a scratch db and diff.
mysqldump --single-transaction --quick forge_app \
  > /home/forge/backups/pre-deploy-$(date +%Y%m%dT%H%M%S).sql

# Rehearse the restore once, so you trust it on the day you actually need it.
mysql scratch_restore_test < /home/forge/backups/pre-deploy-20260718T1230.sql

When a deploy breaks: symptom to command#

After the first green deploy, a handful of failures show up again and again. Pick the symptom you actually hit, and the panel gives the likely cause, the exact recovery command, and the one check that confirms the fix.

Deploy failure triage
The symptom you hit

Select the failure you hit from the list. The panel shows the distinct cause, the exact recovery command, and the one check that confirms the fix. Every case is also listed in full below.

Every symptom and its recovery command, readable without the buttons.

502 Bad Gateway right after a deploy
PHP-FPM is holding a stale bootstrap or a broken cached config, so Nginx has no healthy upstream to reach. It usually follows an optimize step that cached a bad value. Run php artisan optimize:clear and the rest of the recovery for this case.
A migration failed halfway through
One migration in the batch errored, so the schema is half-applied. Laravel does not wrap a whole migration batch in one transaction on MySQL, because MySQL commits DDL implicitly. Run mysql forge_app < /home/forge/backups/pre-deploy.sql and the rest of the recovery for this case.
Workers still run the old code after deploy
Long-lived queue workers hold the previous release in memory. The deploy flipped the current symlink, but nothing told the running workers to reload. Run php artisan queue:restart and the rest of the recovery for this case.
419 or session and CSRF errors after deploy
A stale cached config is serving an old APP_KEY or session driver, or the APP_KEY changed. Old signed cookies and CSRF tokens no longer validate. Run php artisan config:clear and the rest of the recovery for this case.
500 error, log names a missing vendor class
composer install did not complete, or the autoloader is stale, so a class from a package cannot be found. Common after a network blip mid-deploy. Run composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev and the rest of the recovery for this case.
The site is down on a bad release
A release passed the build but is broken at runtime, and the current symlink points at it. The previous release is still on disk, untouched. Run cd /home/forge/app and the rest of the recovery for this case.
Select the failure you hit and read the distinct cause, the exact recovery command, and the confirming check. Selecting a symptom changes the panel; nothing advances on scroll. With JavaScript off, every symptom and its command still read in the list below.

Every branch here is a real incident, not a hypothetical. Because the atomic model keeps the previous release on disk, most of these recoveries are fast. The worst case, a half-applied migration, is why the backup step above is non-negotiable.

Atomic rollback via Forge deployment history#

The atomic model gives one clean recovery for free. When a release is bad, repoint current at the previous release and reload PHP-FPM. Forge's deployment history does this same flip from the dashboard with one click.

atomic rollback · bash
# The clean recovery the atomic model gives you: repoint current at the
# previous release. Forge's deployment history does this same flip in one click.
cd /home/forge/app
ln -nfs releases/20260717T1432 current
sudo service php8.3-fpm reload
# A stale opcache can survive the flip, so reload PHP-FPM to clear it too.

A stale opcache can survive the symlink flip, so the reload matters. In practice, that one reload is what turns a rollback from "still broken" into "recovered."

Nginx, PHP-FPM, and opcache tuning beyond the stock config#

In practice, the stock Forge config is a sane default, not a tuned one. The real numbers come straight from the sizing section, so tuning here is arithmetic, not guesswork.

PHP-FPM pm settings from the sizing math#

PHP-FPM's pm block decides how many request-handling children run. First, set pm to dynamic. Then take pm.max_children from the calculator. The values below are its default $24 droplet output.

/etc/php/8.3/fpm/pool.d/app.conf · ini
; /etc/php/8.3/fpm/pool.d/app.conf
; Values from the sizing calculator's default $24 4 GB / 2 vCPU output.
; pm.max_children = floor(PHP-FPM pool RAM / memory per child).
pm = dynamic
pm.max_children = 44
pm.start_servers = 11
pm.min_spare_servers = 9
pm.max_spare_servers = 27
pm.max_requests = 500   ; recycle a child after 500 requests to cap leaks

opcache and the realpath cache#

opcache is the single biggest PHP performance win, and it needs two different settings for two environments. Switch between them below. Production trusts the compiled bytecode; local development re-checks files so edits show at once.

ini
; php.ini, production. Trust the compiled bytecode; never re-check files.
; validate_timestamps=0 means a deploy MUST clear opcache to load new code,
; which the current-symlink flip plus a PHP-FPM reload already handles.
opcache.enable=1
opcache.memory_consumption=128      ; the sizing calculator's value for 4 GB
opcache.max_accelerated_files=20000 ; Laravel + vendor is about 12k files
opcache.validate_timestamps=0
opcache.interned_strings_buffer=16

Nginx: client_max_body_size and fastcgi timeouts#

Two stock-config omissions cause real errors. First, without client_max_body_size a large upload returns a 413. Second, a slow export can outrun the default fastcgi timeout and return a 502.

/etc/nginx/sites-available/app · nginx
# /etc/nginx/sites-available/app, past the stock Forge config.
# Omit client_max_body_size and a large upload returns 413 Request Entity Too Large.
client_max_body_size 25m;

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    # A slow export needs longer than the 60s default, or it returns a 502.
    fastcgi_read_timeout 120s;
    include fastcgi_params;
}

The production-hardening checklist, the real commands#

Hardening is where "it's a breeze" tutorials wave and move on. So here are the security steps they skip, each with the actual command. None of this is exotic, and all of it is the difference between a server and a breach.

Firewall: ufw and Forge firewall rules#

Because a server should answer only the ports it must, lock the rest down. First, allow SSH and HTTP/S. Then deny everything else. Forge's firewall panel maps directly to these ufw rules.

ufw rules · bash
# Forge's firewall maps to ufw. Allow only SSH and HTTP/S; deny the rest.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp     # SSH
sudo ufw allow 80/tcp     # HTTP, which redirects to HTTPS
sudo ufw allow 443/tcp    # HTTPS
sudo ufw enable
sudo ufw status verbose

SSH: key-only, PasswordAuthentication no, fail2ban#

Key-only SSH is the highest-leverage hardening step there is. Turn off password authentication, disable root login, then add fail2ban to throttle brute-force attempts. A stolen password is worthless once the server refuses passwords entirely.

/etc/ssh/sshd_config · bash
# /etc/ssh/sshd_config. Key-only login is the highest-leverage hardening step.
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes

# Then reload sshd and add fail2ban to throttle brute-force attempts.
sudo systemctl reload ssh
sudo apt-get install -y fail2ban
sudo systemctl enable --now fail2ban

Keep the database off the public interface#

A database that answers the public internet is a breach waiting to happen. Bind MySQL and Redis to localhost or the private network, require a password, and firewall the ports. This one default catches far too many teams.

MySQL and Redis bind config · ini
; /etc/mysql/mysql.conf.d/mysqld.cnf. Keep MySQL off the public interface.
; Bind to localhost, or to the private-network IP if the db is on its own box.
bind-address = 127.0.0.1

# Redis too: bind it to localhost and require a password.
# /etc/redis/redis.conf
bind 127.0.0.1
requirepass a-long-random-string
# Then firewall 3306 and 6379, so neither port ever answers the public internet.

Secrets, tested backups, and monitoring#

Three habits finish the checklist. Keep secrets in Forge's encrypted Environment tab, not in Git. Schedule a database backup that ships off-box and has a rehearsed restore. Then expose one health route an uptime check hits every minute.

secrets, backup, monitoring · bash
# Secrets: keep them in Forge's Environment tab, encrypted at rest, not in git.
# For anything larger, pull from a secrets manager at deploy time.

# Backup: a scheduled dump with a REHEARSED restore, stored off-box.
0 3 * * * mysqldump --single-transaction forge_app | gzip \
  | aws s3 cp - s3://app-backups/db/$(date +\%F).sql.gz

# Monitoring: one health route an uptime check hits every minute.
Route::get('/up', fn () => response()->json(['ok' => true]));

SSL with Let's Encrypt, and the renewal gotcha#

SSL is the one part the tutorials do cover, so this stays brief. Forge installs a Let's Encrypt certificate from the SSL tab in one click, and it schedules the renewal. However, the gotcha they omit is the rate limit. The certificate is valid 90 days and renews at 60, and repeated issuance is rate-limited.

renew, do not reissue · bash
# Forge installs a Let's Encrypt certificate from the site's SSL tab in one click,
# and it schedules the renewal for you. The gotcha the tutorials skip: the cert is
# valid 90 days and renews at 60, and Let's Encrypt rate-limits repeated issuance.
# So never delete-and-reissue in a loop while debugging; renew the existing cert.
sudo certbot renew --dry-run

So while debugging a TLS problem, never delete and reissue in a loop. Instead, renew the existing certificate, because a burst of issuance requests can lock you out for a week. For the broader speed picture once TLS is live, our guide to tuning the deployed app for speed and performance post-launch picks up where this runbook ends.

When not to reach for Forge#

Forge is a single-server tool at heart, and that is its strength, not a flaw. Still, it is worth naming where it stops being the right call, because using it past its lane creates its own operations pain.

In short, Forge fits the single-server and small-fleet Laravel app extremely well. Once the shape becomes a container cluster or a serverless workload, the tool underneath should change with it.

Deploy Laravel app on DigitalOcean with Laravel Forge: common questions

Can you really get a zero-downtime deploy with a single droplet?
Yes. Zero-downtime does not need a load balancer or a second server. Forge builds each release in its own directory and flips one "current" symlink at the end, so the live site only ever points at a fully built release. The switch is a single atomic filesystem operation. In-flight requests finish on the old release, and the next request lands on the new one. The one caveat is a database migration that is not backward-compatible, which no symlink trick can hide. That is why migration safety has its own section above.
Is php artisan migrate --force safe to run on every deploy?
The --force flag is safe; it only tells Artisan to skip the interactive "are you sure" prompt in a non-interactive shell. Without it the deploy hangs or aborts. What is not automatically safe is the migration itself. A migration that drops or renames a column cannot auto-reverse, so pair --force with a pre-deploy backup and the backward-compatible two-deploy pattern for destructive changes. The triage tree above shows the exact restore path when a migration fails halfway.
Do I need Horizon, or is queue:work under Supervisor enough?
Supervisor running queue:work is enough for most single-server apps, and it is what Forge configures from the Queue tab. Horizon adds a dashboard, per-queue metrics, and auto-balancing across queues, which earns its keep once you run several queues or want visibility into throughput and wait time. Either way, the queue-worker count comes from the same sizing math, and either way you must run queue:restart on every deploy so workers load the new code.
How big a droplet do I need to deploy a Laravel app on DigitalOcean with Laravel Forge?
For a single-server app that runs the code, MySQL, Redis, and one or two queue workers on the same box, start at the $24 4 GB / 2 vCPU droplet. The $12 2 GB tier looks cheaper, but MySQL, Redis, and PHP-FPM contend for memory and the box OOMs under real load. Use the sizing calculator on this page to derive pm.max_children, the queue-worker count, and opcache size from your own RAM, vCPU, and app memory profile.
What breaks most often right after the first green deploy?
Stale queue workers running old code, because queue:restart did not fire, and a 502 from a cached bad config are the two most common. Both are in the triage tree above with the exact recovery command. The next most common is a half-applied migration, which is why the backup-before-migrate step and a rehearsed restore matter more than any tuning flag.

Running this stack and want a second read on your droplet sizing, deploy script, or hardening checklist before it carries real traffic? No pressure and no lock-in. Everything above is standard, documented Laravel and Forge you own outright.

Talk through your Laravel deployment

Ronak Makwana

Software Engineer, Atyantik Technologies

Ronak Makwana is a Software Engineer at Atyantik Technologies, a software product studio building web platforms, mobile apps, and integrated systems since 2015. Ronak writes about the software engineering practice behind shipping and maintaining real software.

More from Ronak MakwanaSoftware engineeringHire Laravel developers

Keep reading