<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Deploynix</title>
    <description>The latest articles on DEV Community by Deploynix (@deploynix).</description>
    <link>https://dev.to/deploynix</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3800435%2Fa1f4de22-651f-46e8-adc8-a9da58944683.png</url>
      <title>DEV Community: Deploynix</title>
      <link>https://dev.to/deploynix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deploynix"/>
    <language>en</language>
    <item>
      <title>AI Agents in Your DevOps Loop: What to Automate, What to Gate, and How to Stay Safe</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 23 Sep 2026 11:03:02 +0000</pubDate>
      <link>https://dev.to/deploynix/ai-agents-in-your-devops-loop-what-to-automate-what-to-gate-and-how-to-stay-safe-4ha</link>
      <guid>https://dev.to/deploynix/ai-agents-in-your-devops-loop-what-to-automate-what-to-gate-and-how-to-stay-safe-4ha</guid>
      <description>&lt;p&gt;Earlier this year, a team we talked to gave an AI agent shell access to a staging server and asked it to investigate a disk space alert. The agent did exactly what it was told. It found the largest directory on the volume, reasoned that removing it would free the most space, and deleted it. The directory held the database backups. Nothing about the agent was broken. It behaved like a bright new hire with root access and zero context: the failure mode wasn't incompetence, it was competence without context.&lt;/p&gt;

&lt;p&gt;That story matters because agents are no longer autocomplete. In 2026, coding and ops agents routinely run for minutes or hours and get delegated whole units of work: closing issues, writing tests, drafting migrations, executing refactors, with IDE, CLI, and cloud agents all acting as connected surfaces into your systems (&lt;a href="https://thenewstack.io/5-key-trends-shaping-agentic-development-in-2026/" rel="noopener noreferrer"&gt;The New Stack&lt;/a&gt;, 2026). Infrastructure teams are wiring the same class of tools into deploy pipelines and incident response (&lt;a href="https://www.pulumi.com/blog/ai-predictions-2026-devops-guide/" rel="noopener noreferrer"&gt;Pulumi&lt;/a&gt;, 2026). The question is no longer whether an agent will touch your production loop. It's how much it's allowed to do when it gets there.&lt;/p&gt;

&lt;p&gt;This is our field guide, written from the ops side. We'll cover where agents genuinely earn their keep today, a four-level autonomy ladder for deciding how much rope to give them, the mechanisms that turn "please be careful" into actual enforcement, and a 30-day plan for adopting agents without waking up to a deleted backup directory. Neither hype nor doom. Just the operational reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Do AI Agents Actually Help in Ops Today?
&lt;/h2&gt;

&lt;p&gt;The honest answer: agents deliver the most value where they read a lot and change nothing. AI tooling in DevOps increasingly works by analyzing telemetry, logs, metrics, and traces, to detect anomalies and recommend remediation rather than execute it (&lt;a href="https://spacelift.io/blog/ai-devops-tools" rel="noopener noreferrer"&gt;Spacelift&lt;/a&gt;, 2026). That read-heavy, write-light profile is exactly where you should start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log Triage and Anomaly Summarization
&lt;/h3&gt;

&lt;p&gt;An agent that watches your logs and answers "what changed in the last hour, and does it matter?" is quietly one of the highest-value tools you can deploy. Humans are terrible at scanning 40,000 log lines at 3 a.m. Agents are excellent at it, and a read-only agent can't hurt you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Tireless pattern matching across huge volumes of text. Correlating a spike in queue latency with a deploy timestamp takes an agent seconds. It also writes better summaries than a stressed engineer mid-incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; First-pass triage on alerts, daily "anything weird?" digests, and correlating symptoms across services. If you run AI-heavy Laravel workloads yourself, the telemetry from &lt;a href="https://deploynix.io/blog/deploying-ai-powered-laravel-apps-queues-streaming-timeouts" rel="noopener noreferrer"&gt;queues, streaming responses, and timeout tuning&lt;/a&gt; is exactly the kind of noisy signal agents digest well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Summaries carry the agent's confidence, not its accuracy. A plausible wrong theory delivered fluently is more dangerous than no theory. Treat every summary as a lead, not a diagnosis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Writing and Migration Drafting
&lt;/h3&gt;

&lt;p&gt;Agents are strong at generating the boring, high-coverage work nobody enjoys: Pest tests for edge cases, migration drafts, factory states. The output lands in a pull request, which means the human gate already exists in your workflow. You review it like any junior engineer's PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Volume and patience. An agent will happily write the fifteenth validation-rule test case with the same care as the first. Draft migrations catch schema drift you'd otherwise notice in code review or, worse, in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Backfilling test coverage, drafting rollback migrations alongside forward ones, and keeping factories in sync with schema changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Generated tests can assert the bug instead of the intent. If the code is wrong, an agent writing tests from that code will lovingly enshrine the wrongness. Review the assertions, not just the syntax.&lt;/p&gt;

&lt;h3&gt;
  
  
  PR Review, Incident Timelines, and Runbook Drafting
&lt;/h3&gt;

&lt;p&gt;Post-incident, an agent that reconstructs the timeline from logs, deploy events, and chat transcripts saves hours of archaeology. The same applies to drafting runbooks from what actually happened rather than what someone remembered a week later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Total recall of the evidence. An agent doesn't forget that the first error appeared four minutes before the deploy everyone blamed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; First-draft incident reports, runbook skeletons from real incidents, and PR review comments that catch mechanical issues before a human reviewer spends attention on them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Timelines are only as good as the log coverage feeding them. And agent PR reviews tend toward confident nitpicking; keep a human responsible for the approval itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Autonomy Ladder: How Much Should an Agent Be Allowed to Do?
&lt;/h2&gt;

&lt;p&gt;We think about agent permissions as a ladder with four rungs. Each rung is a distinct trust level with distinct enforcement, and an agent should climb one rung at a time, per task class, based on a track record you can audit. Nobody starts at the top.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: Read-Only Observer
&lt;/h3&gt;

&lt;p&gt;The agent can query logs, metrics, deploy history, and monitoring endpoints. It cannot mutate anything, and that guarantee comes from the credential, not the prompt. This is where every agent starts, and where plenty should stay permanently. A read-only observer's worst failure is a wrong summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 2: Proposer
&lt;/h3&gt;

&lt;p&gt;The agent can create artifacts that humans act on: pull requests, suggested fixes, draft runbooks, proposed config changes. Nothing executes without a human merging or approving. This rung captures most of the productivity win with almost none of the blast radius, because your existing review process is the gate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 3: Gated Executor
&lt;/h3&gt;

&lt;p&gt;The agent can trigger pre-approved action classes, but each execution requires explicit human confirmation, or the action comes from a narrow allowlist with deny patterns. Think "restart the queue worker on staging" from a fixed menu, not free-form shell access. The action vocabulary is fixed in advance; the agent only picks from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 4: Autonomous, Within a Very Small Box
&lt;/h3&gt;

&lt;p&gt;Full autonomy is defensible only for tasks that are narrow, reversible, and rate-limited, all three at once. Rotating a staging cache, retrying a failed queue job, scaling a worker pool within preset bounds. If an action is irreversible or customer-visible, it doesn't belong on this rung no matter how good the agent's track record looks.&lt;/p&gt;

&lt;p&gt;Here's how real ops tasks map onto the ladder:&lt;/p&gt;

&lt;p&gt;TaskAutonomy levelWhyLog triage and anomaly summariesAutomate (Level 1)Read-only; worst case is a bad summaryDaily metrics digestAutomate (Level 1)No mutation path existsDrafting tests, migrations, runbooksAutomate (Level 2)Output lands in PR review; humans mergeOpening fix PRs for known error classesAutomate (Level 2)Existing code review is the gateRestarting a staging serviceGate (Level 3)Reversible, but state-changing; confirm firstClearing an application cacheGate (Level 3)Usually safe, occasionally load-spikingRetrying failed queue jobs, capped per hourAutomate (Level 4)Narrow, reversible, rate-limitedProduction deploysGate (Level 3)Reversible with instant rollback, but customer-visibleProduction database mutationsForbidOften irreversible; evidence rarely justifies urgencyDeleting files, servers, or backupsForbidThe deleted-directory class of helpfulnessDNS and SSL changesForbidSlow to detect, slow to reverse, customer-visibleModifying its own permissions or allowlistForbidGates that a gated party can edit are not gates&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should Stay Behind a Human Gate, Possibly Forever?
&lt;/h2&gt;

&lt;p&gt;Some action classes should require a human for the foreseeable future, and we'd rather say that plainly than hedge. Production data mutations. Service restarts and deletions in production. DNS and SSL changes. Anything irreversible. Anything a customer can see happen. The common thread isn't that agents are bad at these tasks. It's that the cost of a rare wrong execution dwarfs the cost of a human spending ninety seconds confirming.&lt;/p&gt;

&lt;p&gt;The test we apply before any state-changing action is what we call the evidence check: does the evidence actually support this specific action, or does the symptom merely pattern-match a known failure? The disk-space story fails the evidence check perfectly. "Disk is full" pattern-matches "delete the biggest directory," and that's precisely the reasoning shortcut agents take. The evidence supported investigating the biggest directory. It never supported deleting it. Humans make this exact mistake too, which is why the check is worth writing into your runbooks for people and agents alike.&lt;/p&gt;

&lt;p&gt;A useful heuristic: if your &lt;a href="https://deploynix.io/blog/your-server-is-down-a-step-by-step-incident-response-playbook-for-deploynix-users" rel="noopener noreferrer"&gt;incident response playbook&lt;/a&gt; says "verify before acting" at a given step, an agent must not own that step alone. Pattern matching is what agents do. Verification against ground truth is what the gate is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Make Gates Real Instead of Aspirational?
&lt;/h2&gt;

&lt;p&gt;A rule that lives in a system prompt is a suggestion. A rule that lives in a credential is a control. Governance for autonomous coding agents has matured into its own product category in 2026, with dedicated tooling for policy, permissions, and oversight (&lt;a href="https://checkmarx.com/learn/ai-security/top-12-ai-developer-tools-in-2026-for-security-coding-and-quality/" rel="noopener noreferrer"&gt;Checkmarx&lt;/a&gt;, 2026), and the pattern underneath all of it is the same: enforce at the boundary, not in the prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scoped, Least-Privilege API Tokens
&lt;/h3&gt;

&lt;p&gt;Read-only tokens exist for a reason, and agents are the reason to finally use them. An observer agent should hold a token that structurally cannot mutate anything, scoped to the narrowest team or project that covers its job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Observer agent: read-only token scoped to one team.&lt;/span&gt;
&lt;span class="c"&gt;# It can see server metrics and deploy history. It cannot change either.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://app.deploynix.io/api/v1/servers/42/metrics &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DEPLOYNIX_READONLY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json"&lt;/span&gt;

&lt;span class="c"&gt;# The same token attempting a mutation fails at the credential layer,&lt;/span&gt;
&lt;span class="c"&gt;# regardless of what the agent was convinced it should do:&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.deploynix.io/api/v1/servers/42/restart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DEPLOYNIX_READONLY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 403 Forbidden: token lacks the servers:write scope&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 403 is the entire philosophy in one response code. The agent's reasoning quality is irrelevant. The credential decides.&lt;/p&gt;

&lt;h3&gt;
  
  
  Command Allowlists With Deny Patterns
&lt;/h3&gt;

&lt;p&gt;For anything approaching Level 3, free-form shell access is the wrong interface. Give the agent a fixed vocabulary of commands and a set of deny patterns that block chaining, privilege escalation, and secrets access. A sketch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent-allowlist.yml&lt;/span&gt;
&lt;span class="na"&gt;allowed_commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;queue:restart"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cache:clear"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;horizon:status"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;df&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-m"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tail&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-n&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;200&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;storage/logs/laravel.log"&lt;/span&gt;

&lt;span class="na"&gt;deny_patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;"&lt;/span&gt;          &lt;span class="c1"&gt;# no command chaining&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;||"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;;"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|"&lt;/span&gt;           &lt;span class="c1"&gt;# no piping into surprises&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sudo"&lt;/span&gt;        &lt;span class="c1"&gt;# no privilege escalation&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;         &lt;span class="c1"&gt;# no deletions, ever, from this surface&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# no redirects overwriting files&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.env"&lt;/span&gt;        &lt;span class="c1"&gt;# no secrets paths&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;        &lt;span class="c1"&gt;# no exfiltration or arbitrary downloads&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;

&lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;max_duration_minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
  &lt;span class="na"&gt;audit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immutable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Understand what this layer is for. The allowlist stops accidents: the well-meaning wrong command, the chained cleanup that goes one directory too far. It does not stop a determined adversary, and it shouldn't have to. OS-level privilege boundaries stop everything else: run the agent's session as a user that lacks the permissions to do real damage even if a clever string slips through the pattern filter. Allowlists for accidents, privilege boundaries for everything else. Never rely on the first to do the second's job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approval Workflows in CI
&lt;/h3&gt;

&lt;p&gt;The cleanest gated-executor pattern we know is a CI pipeline where the agent proposes and a human approves before anything runs. GitHub Actions environments make this nearly free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent-proposed-deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;agent_summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Agent's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;evidence&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;proposed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;action"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;propose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record the agent's proposal&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "${{ inputs.agent_summary }}" &amp;gt;&amp;gt; "$GITHUB_STEP_SUMMARY"&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;propose&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# This environment requires a human reviewer in repo settings.&lt;/span&gt;
    &lt;span class="c1"&gt;# The agent cannot approve; approval is a GitHub permission it doesn't hold.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Trigger deployment&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -s -X POST \&lt;/span&gt;
            &lt;span class="s"&gt;https://app.deploynix.io/api/v1/sites/${{ vars.SITE_ID }}/deploy \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Authorization: Bearer ${{ secrets.DEPLOYNIX_DEPLOY_TOKEN }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent drafts the change, opens the workflow, and writes up its evidence. A human reads that evidence, applies the evidence check, and clicks approve or doesn't. We covered the broader pipeline pattern in our guide to &lt;a href="https://deploynix.io/blog/cicd-for-laravel-github-actions-deploynix-api-and-automated-deployments" rel="noopener noreferrer"&gt;CI/CD for Laravel with GitHub Actions and the Deploynix API&lt;/a&gt;, and it extends to agent-initiated deploys without modification: the approval step neither knows nor cares whether a human or an agent opened the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limits, Budgets, and Audit Logs You Actually Read
&lt;/h3&gt;

&lt;p&gt;Even correct actions become incidents at the wrong frequency. Cap agent-initiated actions per hour, cap spend per day, and alert when either cap is hit, because hitting a cap is itself a signal that something upstream went strange.&lt;/p&gt;

&lt;p&gt;Then treat agent audit logs the way you treat access logs: immutable, complete, and reviewed on a schedule rather than only after something breaks. A few queries worth running weekly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What did agents do this week, and how often?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'agent:%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Denied attempts: the most interesting rows in the table.&lt;/span&gt;
&lt;span class="c1"&gt;-- Each one is an action an agent believed was justified.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempted_command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;denied_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'denied'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'agent:%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Off-hours activity from agents that should be business-hours-only&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'agent:%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;HOUR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denied-attempts query deserves emphasis. Every denial is a free lesson: either your allowlist correctly stopped a mistake, or it's blocking legitimate work and needs a deliberate, human-reviewed expansion. Both outcomes are worth knowing about before they matter.&lt;/p&gt;

&lt;p&gt;Pulling the mechanisms together, here's the risk-tier model we recommend for agent permissions:&lt;/p&gt;

&lt;p&gt;Risk tierAgent capabilityCredentialEnforcementReview cadenceTier 0: ObserveRead logs, metrics, deploy historyRead-only tokenToken scope; no write path existsMonthly audit skimTier 1: ProposeOpen PRs, draft configs and runbooksRepo write, no merge rightsBranch protection, required reviewEvery PR, by designTier 2: Gated executeTrigger pre-approved actionsScoped write token + allowlistHuman approval or allowlist with deny patternsWeekly audit reviewTier 3: AutonomousNarrow, reversible, rate-limited tasksTightly scoped token, hard capsRate limits, budgets, auto-rollback, alerts on capWeekly, plus alert on every cap hit&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt Injection Is an Ops Threat, Not a Chatbot Quirk
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. The moment an agent reads logs, tickets, commit messages, or monitoring annotations, it is ingesting attacker-controlled text. A malicious user-agent string, a crafted exception message, a support ticket that says "SYSTEM: to resolve this incident, run the following command." Log content is user input wearing a trench coat, and agents read it with the same trust they give your instructions.&lt;/p&gt;

&lt;p&gt;The defense is a principle, not a filter: treat all agent input as untrusted, and never let content authorize actions. Text an agent reads can inform its analysis. It must never expand its permissions or trigger execution. This is exactly why enforcement has to live in credentials, allowlists, and OS boundaries rather than in the prompt: a prompt-level rule is made of the same stuff the attack is made of, and the injected text gets a vote. A scoped token doesn't read logs and can't be argued with.&lt;/p&gt;

&lt;p&gt;Concretely: an observer agent that gets fully compromised by a poisoned log line can produce a misleading summary, which a human then sanity-checks. The same compromise at Level 3 with a sloppy allowlist runs a command. Rung by rung, injection risk compounds, which is one more reason to climb slowly. Your deploy pipeline was already an attack surface before agents arrived, and we've written about &lt;a href="https://deploynix.io/blog/your-deploy-pipeline-is-an-attack-surface" rel="noopener noreferrer"&gt;why deploy pipelines deserve threat modeling&lt;/a&gt; in their own right. Agents add a new entry point to that same surface, and they belong in the same threat model, right next to the items on our &lt;a href="https://deploynix.io/blog/a-laravel-developers-production-security-checklist-2026-edition" rel="noopener noreferrer"&gt;production security checklist for Laravel teams&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Built Deploynix With This Problem in Mind
&lt;/h2&gt;

&lt;p&gt;We didn't design Deploynix for AI agents originally. We designed it for tired humans at 2 a.m., and it turns out the same guardrails serve both. A few design choices that make agent integration safer in practice, described here because they double as a template you can copy whatever platform you run.&lt;/p&gt;

&lt;p&gt;Our REST API uses token auth with scopes, and tokens can be constrained to specific teams and organizations. That means an observer agent gets a token that can read &lt;a href="https://deploynix.io/blog/using-the-deploynix-api-to-automate-your-deployment-workflow" rel="noopener noreferrer"&gt;deploy status and server metrics through the API&lt;/a&gt; but structurally cannot trigger a deploy or touch a server. Least privilege isn't a policy document; it's the shape of the credential.&lt;/p&gt;

&lt;p&gt;Every server action is recorded in the server logs, whether a human clicked it, a CI pipeline called it, or an agent's token triggered it. That gives you the immutable audit trail the queries above assume, with no extra instrumentation on your side.&lt;/p&gt;

&lt;p&gt;The browser web terminal is the piece we find most relevant to agent design. It enforces a command allowlist with deny patterns: no command chaining, no sudo, no access to secrets paths, and sessions expire on a timer. We built it so a team member could safely run diagnostics from a browser without holding SSH keys. It works, unchanged, as a working reference for gated agent execution: a constrained vocabulary, hostile-input filtering, and short-lived sessions. If you're designing an execution surface for an agent, that trio is the starting spec.&lt;/p&gt;

&lt;p&gt;Monitoring webhooks and metrics are readable without mutation rights, so an agent can consume alerts and telemetry with zero write capability. And zero-downtime deploys with instant rollback matter here for a subtle reason: reversibility is a safety property. The rollback button is what makes "gate deploys at Level 3" a reasonable policy instead of a terrifying one, because a wrong approval costs minutes, not an evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-Day Adoption Plan That Won't Bite You
&lt;/h2&gt;

&lt;p&gt;You don't need a committee to start. You need a month, a staging environment, and the discipline to not skip rungs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1: Read-only observer on staging.&lt;/strong&gt; Issue a read-only token. Point the agent at staging logs and metrics. Have it produce a daily digest and an on-demand "what changed?" answer. Grade its summaries against what actually happened. You're calibrating trust, not extracting value yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 2: Proposer mode.&lt;/strong&gt; Let the agent open PRs: test backfills, a runbook drafted from a real past incident, a proposed fix for a recurring staging error. Review everything with the same rigor you'd give a new hire, and keep notes on what it gets confidently wrong. Those notes become your forbid list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 3: Gated actions on staging.&lt;/strong&gt; Build the allowlist, small. Five commands is plenty. Wire the approval workflow so every execution needs a human click, and run the agent's sessions as a low-privilege OS user. Let it handle real staging alerts under supervision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 4: Review the audit logs and decide.&lt;/strong&gt; Run the queries. Read every denied attempt. Count how often the agent's proposed action passed the evidence check versus merely pattern-matching the symptom. Then decide, per task class, which rung each one has earned. Some tasks will graduate. Some will stay at Level 1 forever, and that is a perfectly good outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Modes Nobody Puts in the Demo
&lt;/h2&gt;

&lt;p&gt;Confident wrong diagnoses are the big one. An agent will hand you a beautifully structured incident summary pointing at the wrong cause, and its fluency will make the error more persuasive, not less. The fix is cultural: teach your team that agent output is a lead from a smart colleague who wasn't in the room, never a verdict.&lt;/p&gt;

&lt;p&gt;Alert-fatigue automation is subtler. Give an agent authority to auto-resolve "known noisy" alerts, and it will quietly widen its definition of noisy. Six weeks later, a real incident matches a noisy pattern and gets summarized instead of escalated. Cap auto-resolution, audit what got suppressed, and keep a human owning the alert taxonomy.&lt;/p&gt;

&lt;p&gt;And the deleted-directory class of helpfulness never fully goes away, because it isn't a bug. It's what goal-directed reasoning without context does under pressure. You don't fix it with better prompts. You fix it with credentials that can't delete, allowlists that deny &lt;code&gt;rm&lt;/code&gt;, OS users that lack the permission anyway, and rollbacks for the cases something slips through. Defense in depth, because the agent's judgment is the thing you're defending against.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should an AI agent ever have production shell access?
&lt;/h3&gt;

&lt;p&gt;Not free-form access, no. If an agent needs to execute anything in production, it should be through a fixed allowlist with deny patterns, running as a low-privilege OS user, with every command logged and sessions that expire. In our experience, teams that think they need agent shell access usually need three or four allowlisted commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are read-only agent tokens really safe?
&lt;/h3&gt;

&lt;p&gt;Safe from mutation, yes: a properly scoped token has no write path, so the worst structural outcome is a wrong or misleading summary. The residual risks are data exposure, so scope reads to the narrowest team, and prompt injection shaping the agent's analysis, so humans verify before acting on any summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do we defend log-reading agents against prompt injection?
&lt;/h3&gt;

&lt;p&gt;Assume it will happen and make it not matter. Never let anything the agent reads authorize an action: enforcement lives in token scopes, allowlists, and OS privilege boundaries, which injected text cannot talk its way past. Keep injection-exposed agents at the observer or proposer rungs, where a compromised agent produces bad text, not executed commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the single best first use of an agent in ops?
&lt;/h3&gt;

&lt;p&gt;Log triage and anomaly summarization on staging, read-only. It's high value from day one, the failure mode is harmless, and grading the agent's summaries against reality gives you the trust data you need before considering any higher rung on the ladder.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do Next
&lt;/h2&gt;

&lt;p&gt;Agents earn a place in the DevOps loop the same way people do: by starting with read access, showing their work, and building a track record you can audit. The teams getting real value in 2026 aren't the ones granting the most autonomy. They're the ones with the clearest gates, the smallest allowlists, and the habit of reading their audit logs before something forces them to.&lt;/p&gt;

&lt;p&gt;Your one next step: this week, issue a single read-only API token, point an agent at your staging logs, and have it answer one question every morning: "what changed in the last 24 hours, and does it matter?" Grade it for two weeks. Everything else in this guide builds on what you learn from that.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>automation</category>
      <category>security</category>
    </item>
    <item>
      <title>Modern WordPress Hosting in 2026: Nginx, PHP 8.4, and Object Caching on a VPS</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Mon, 21 Sep 2026 11:03:03 +0000</pubDate>
      <link>https://dev.to/deploynix/modern-wordpress-hosting-in-2026-nginx-php-84-and-object-caching-on-a-vps-5cmk</link>
      <guid>https://dev.to/deploynix/modern-wordpress-hosting-in-2026-nginx-php-84-and-object-caching-on-a-vps-5cmk</guid>
      <description>&lt;p&gt;Open the invoice for a typical managed WordPress plan and you'll see $25 to $45 per month, per site. Now look at what that money actually buys under the hood: a container or VM slice running nginx, PHP-FPM, MySQL, and a Redis-compatible object cache. That exact stack fits comfortably on a $6 to $12 VPS from DigitalOcean, Hetzner, or Vultr, and the VPS will usually give you more CPU, more RAM, and no artificial "visits per month" ceiling.&lt;/p&gt;

&lt;p&gt;The gap between those two numbers is what you're paying for convenience. Sometimes that's a fair trade. A managed host handles updates, security patching, staging environments, and support tickets at 2 a.m. But if you're a developer who already runs application servers, or an agency hosting a dozen client sites, the math tilts hard toward self-hosting. WordPress still powers roughly 43% of all websites (&lt;a href="https://w3techs.com/technologies/details/cm-wordpress" rel="noopener noreferrer"&gt;W3Techs&lt;/a&gt;, 2025), yet it's routinely hosted with less production discipline than any Laravel or Rails app would get.&lt;/p&gt;

&lt;p&gt;That's the argument of this post: WordPress deserves the same treatment as an application framework. Proper nginx configuration, a tuned PHP 8.4 FPM pool, a real object cache, page caching with sane invalidation, actual cron instead of WP-Cron, and backups you've tested. We'll build that stack layer by layer, and we'll be honest about where managed hosts still earn their premium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Self-Host WordPress in 2026?
&lt;/h2&gt;

&lt;p&gt;For most developers and agencies, yes. A single mid-range VPS runs multiple WordPress sites faster than a $30/month managed plan runs one, because you control the caching stack and nobody is metering your traffic. The people who should not self-host are the ones with no interest in operations at all, and we'll get to them.&lt;/p&gt;

&lt;p&gt;Here's the honest comparison.&lt;/p&gt;

&lt;p&gt;Managed WordPress hostVPS (self-managed or platform-managed)Monthly cost$25–45 per site$6–12 for the whole serverSites includedUsually 1, extras cost moreAs many as the hardware handlesTraffic limits"Visits per month" caps are commonNone beyond actual capacityPHP version controlLimited menu, slow to add new versionsAny version, per siteObject cacheOften a paid add-onIncluded (Valkey or Redis)Server config accessLittle to noneFull nginx, FPM, MySQL controlStagingOne-clickYou build it (wp-cli makes this fast)Core and plugin updatesOften handled for youYour responsibilitySupport24/7, WordPress-savvy humansYourself, plus your platform's tooling&lt;/p&gt;

&lt;h3&gt;
  
  
  The Case for Managed WordPress Hosting
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Someone else owns the pager. Managed hosts patch vulnerable plugins overnight, run malware scans, provide one-click staging, and answer support tickets from people who know WordPress deeply. Their platforms are tuned specifically for WP, and when something breaks at 3 a.m., it's their problem first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Bloggers and small businesses with zero ops appetite, sites where $360/year is a rounding error compared to the cost of an hour of downtime, and anyone who wants to never think about a server again. If you don't know what SSH is and don't want to learn, pay the premium. It's the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You're renting convenience at a 3-5x markup over the underlying compute. Visit caps punish success. PHP version upgrades arrive on the host's schedule, not yours. And you can't fix what you can't configure: if the host's caching layer misbehaves with your WooCommerce setup, you file a ticket and wait.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Case for a VPS
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Full control over every layer, dramatically better price-to-performance, no per-site pricing, and the freedom to run the same modern stack your application code gets. One $12 server comfortably hosts several low-to-medium traffic WordPress sites with capacity to spare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers, agencies managing client portfolios, WooCommerce stores that have outgrown shared hosting caps, and anyone who already operates servers for other projects. If you're coming from cPanel-style shared hosting, our &lt;a href="https://deploynix.io/blog/migrating-from-shared-hosting-to-deploynix-a-complete-guide" rel="noopener noreferrer"&gt;complete migration guide&lt;/a&gt; covers the move end to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Updates, backups, and security are on you unless a platform automates them. There's a learning curve around nginx and PHP-FPM. And an unmaintained VPS is worse than a mediocre managed host, because WordPress is the most attacked CMS on the internet and an unpatched install will eventually get found.&lt;/p&gt;

&lt;p&gt;Decided the VPS route is for you? Good. Here's the stack, from the bottom up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Modern WordPress Stack Look Like?
&lt;/h2&gt;

&lt;p&gt;Four layers: nginx terminating TLS and serving static files, PHP 8.4 behind FPM executing WordPress, MySQL or MariaDB storing content, and Valkey holding the object cache. Each layer has WordPress-specific tuning that generic tutorials skip. Get all four right and a modest VPS feels like expensive hosting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nginx: The Front Controller Pattern
&lt;/h3&gt;

&lt;p&gt;WordPress, like Laravel, is a front controller application: every dynamic request funnels through a single &lt;code&gt;index.php&lt;/code&gt;. Apache with &lt;code&gt;.htaccess&lt;/code&gt; was the historical default, but nginx handles high connection counts with far less memory, and its config is explicit rather than scattered across per-directory override files. The same principles from our post on &lt;a href="https://deploynix.io/blog/optimizing-nginx-for-laravel-configs-that-actually-matter" rel="noopener noreferrer"&gt;nginx configs that actually matter&lt;/a&gt; apply here, with WordPress-specific additions.&lt;/p&gt;

&lt;p&gt;A production-ready server block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the http context:&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=wplogin:10m&lt;/span&gt; &lt;span class="s"&gt;rate=1r/s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/example.com/current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.php&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Front controller: try the file, then the directory, then WordPress&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.4-fpm-example.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Long-lived caching for static assets&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff2?)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# XML-RPC is a brute-force and amplification target. Block it.&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/xmlrpc.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Rate-limit login attempts at the edge&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=wplogin&lt;/span&gt; &lt;span class="s"&gt;burst=2&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.4-fpm-example.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Never serve dotfiles (except ACME challenges)&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;/\.(?!well-known)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here. First, &lt;code&gt;try_files $uri $uri/ /index.php?$args&lt;/code&gt; is the whole routing story: static file if it exists, otherwise hand the request to WordPress. Second, PHP-FPM connects over a Unix socket, not TCP, which skips the network stack entirely for same-host communication. Third, static assets get 30-day cache headers and skip access logging, which removes the majority of log noise and lets browsers stop re-requesting your theme's CSS on every page view.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;xmlrpc.php&lt;/code&gt; and &lt;code&gt;wp-login.php&lt;/code&gt; rules are security controls, and we'll come back to why they're non-negotiable.&lt;/p&gt;

&lt;h3&gt;
  
  
  PHP 8.4: Why the Version Actually Matters
&lt;/h3&gt;

&lt;p&gt;WordPress officially lists PHP 7.4 as its minimum recommendation, which tells you how conservative the project is: 7.4 stopped receiving security fixes in November 2022. Core has been compatible with the PHP 8.x line for years, and running WordPress on PHP 8.4 is measurably faster than on any 7.x release. The engine improvements that started with PHP 8.0's JIT and continued through 8.4's optimizations benefit WordPress the same way they benefit any PHP application. We covered the specifics in &lt;a href="https://deploynix.io/blog/php-84-in-production-new-features-that-make-your-laravel-app-faster" rel="noopener noreferrer"&gt;PHP 8.4 in production&lt;/a&gt;, and everything there applies to WordPress core.&lt;/p&gt;

&lt;p&gt;The honest caveat: plugins lag. Core is clean on 8.4, but a fifteen-year-old plugin with a single active developer may throw deprecation notices or fatal errors. Test your specific plugin set on 8.4 in staging before switching production. If one plugin blocks you, that's often a signal to replace the plugin, not to stay on old PHP.&lt;/p&gt;

&lt;p&gt;Beyond the version, FPM pool sizing is where most WordPress servers are misconfigured. WordPress workers are heavier than a slim API's: a request touching a page builder and a dozen plugins can use 80–150 MB. Size &lt;code&gt;pm.max_children&lt;/code&gt; from real memory, not optimism: take the RAM you can spare for PHP, divide by your observed per-worker usage, and leave headroom for MySQL and Valkey.&lt;/p&gt;

&lt;p&gt;A sane starting pool for a 4 GB server hosting one busy WordPress site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[example]&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;example&lt;/span&gt;
&lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;example&lt;/span&gt;
&lt;span class="py"&gt;listen&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/run/php/php8.4-fpm-example.sock&lt;/span&gt;
&lt;span class="py"&gt;listen.owner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;www-data&lt;/span&gt;
&lt;span class="py"&gt;listen.group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;www-data&lt;/span&gt;

&lt;span class="py"&gt;pm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dynamic&lt;/span&gt;
&lt;span class="py"&gt;pm.max_children&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;12&lt;/span&gt;
&lt;span class="py"&gt;pm.start_servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;pm.min_spare_servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;pm.max_spare_servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6&lt;/span&gt;

&lt;span class="c"&gt;; Recycle workers to contain plugin memory leaks
&lt;/span&gt;&lt;span class="py"&gt;pm.max_requests&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;500&lt;/span&gt;

&lt;span class="err"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[memory_limit]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;256M&lt;/span&gt;

&lt;span class="c"&gt;; OPcache: WordPress ships thousands of PHP files
&lt;/span&gt;&lt;span class="err"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[opcache.memory_consumption]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;192&lt;/span&gt;
&lt;span class="err"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[opcache.max_accelerated_files]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;20000&lt;/span&gt;
&lt;span class="err"&gt;php_admin_value&lt;/span&gt;&lt;span class="nn"&gt;[opcache.interned_strings_buffer]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two notes. &lt;code&gt;pm.max_requests = 500&lt;/code&gt; quietly recycles each worker after 500 requests, which is cheap insurance against the slow memory leaks that badly written plugins introduce. And OPcache deserves real memory: a WordPress install with a page builder and twenty plugins easily exceeds 10,000 PHP files, so the default &lt;code&gt;max_accelerated_files&lt;/code&gt; will overflow and silently hurt your hit rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  MySQL or MariaDB: Tuning for the WordPress Schema
&lt;/h3&gt;

&lt;p&gt;WordPress's schema is famously write-light and read-heavy, with two problem children: &lt;code&gt;wp_options&lt;/code&gt; and &lt;code&gt;wp_postmeta&lt;/code&gt;. The options table gets queried on every single request, and postmeta grows into millions of rows on content-heavy or WooCommerce sites, with queries joining it repeatedly.&lt;/p&gt;

&lt;p&gt;You don't need exotic tuning. Three things cover most cases. Set &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; so your working set fits in memory: on a 4 GB server sharing duties with PHP, 512 MB to 1 GB is reasonable, and if your whole database is 300 MB then anything above that means every hot read is served from RAM. Leave &lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt; at 1 for durability unless you have a measured reason not to. And periodically check &lt;code&gt;wp_options&lt;/code&gt; for autoloaded bloat: &lt;code&gt;SELECT SUM(LENGTH(option_value)) FROM wp_options WHERE autoload='yes';&lt;/code&gt; returning more than a megabyte or two means abandoned plugins left junk that loads on every request. Clean it out.&lt;/p&gt;

&lt;p&gt;But the real fix for repetitive database load isn't in MySQL at all. It's the next layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Valkey Object Caching: The Fix for Repeat Queries
&lt;/h3&gt;

&lt;p&gt;Here's what happens without an object cache: every request, WordPress re-queries the same options, the same post metadata, the same term relationships. The queries are individually fast, but there are dozens of them per page, and they're identical from one request to the next. A persistent object cache stores those results in memory, so request number two skips most of the database work entirely.&lt;/p&gt;

&lt;p&gt;Valkey, the open-source Redis fork that emerged after the 2024 license change, speaks the Redis protocol, which means the standard Redis Object Cache plugin works against it without modification. Point the plugin at Valkey and WordPress can't tell the difference.&lt;/p&gt;

&lt;p&gt;Setup with wp-cli takes a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;redis-cache &lt;span class="nt"&gt;--activate&lt;/span&gt;

wp config &lt;span class="nb"&gt;set &lt;/span&gt;WP_REDIS_HOST 127.0.0.1
wp config &lt;span class="nb"&gt;set &lt;/span&gt;WP_REDIS_PORT 6379
wp config &lt;span class="nb"&gt;set &lt;/span&gt;WP_CACHE_KEY_SALT &lt;span class="s2"&gt;"example.com:"&lt;/span&gt;

&lt;span class="c"&gt;# Drops the object-cache.php drop-in into wp-content&lt;/span&gt;
wp redis &lt;span class="nb"&gt;enable&lt;/span&gt;

&lt;span class="c"&gt;# Verify&lt;/span&gt;
wp redis status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WP_CACHE_KEY_SALT&lt;/code&gt; line matters when multiple sites share one Valkey instance: it namespaces keys so sites can't collide. After enabling, check your MySQL query count per page. On plugin-heavy sites the drop is dramatic, and it's the single highest-leverage change on this whole list for logged-in and dynamic traffic, because it's the one cache layer that helps even when full-page caching can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do the Caching Layers Fit Together?
&lt;/h2&gt;

&lt;p&gt;A well-configured WordPress server runs four distinct caches, and confusing them is how people end up "clearing the cache" five times without fixing anything. Each layer caches a different artifact, lives in a different place, and invalidates differently.&lt;/p&gt;

&lt;p&gt;LayerWhat it cachesWhere it livesHow it invalidatesOPcacheCompiled PHP bytecodePHP-FPM shared memoryTimestamp check or FPM reload on deployObject cache (Valkey)Query results, options, transientsValkey memoryWordPress flushes affected keys on writesPage cache (fastcgi_cache)Full HTML responsesNginx cache path on diskTTL expiry, or explicit purge on publishCDNStatic assets, optionally HTMLEdge locations worldwideCache-Control headers, purge API&lt;/p&gt;

&lt;p&gt;The mental model: OPcache saves compiling code, the object cache saves querying the database, the page cache saves running WordPress at all, and the CDN saves the request from reaching your server. They stack. A cache-miss on the page layer still benefits from the object cache underneath, which is why you want both rather than treating them as alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page Caching with fastcgi_cache
&lt;/h2&gt;

&lt;p&gt;For anonymous traffic, the fastest WordPress response is one where WordPress never runs. Nginx's &lt;code&gt;fastcgi_cache&lt;/code&gt; stores the rendered HTML and serves repeat visitors directly, at static-file speed, with PHP-FPM completely out of the loop. This is what most managed WordPress hosts run under the marketing name for their "edge cache."&lt;/p&gt;

&lt;p&gt;The config, with the bypass rules that make it safe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the http context:&lt;/span&gt;
&lt;span class="k"&gt;fastcgi_cache_path&lt;/span&gt; &lt;span class="n"&gt;/var/cache/nginx/example&lt;/span&gt; &lt;span class="s"&gt;levels=1:2&lt;/span&gt;
                   &lt;span class="s"&gt;keys_zone=EXAMPLE:100m&lt;/span&gt; &lt;span class="s"&gt;inactive=60m&lt;/span&gt; &lt;span class="s"&gt;max_size=512m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;fastcgi_cache_key&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$scheme$request_method$host$request_uri&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# In the server block:&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# Never cache writes or query-string requests&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;POST)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt; &lt;span class="s"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Never cache admin, API, feeds, or auth pages&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;"/wp-admin/|/wp-json/|wp-login&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.php|/feed/|sitemap.*&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.xml")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Never cache for logged-in users, commenters, or active carts&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_cookie&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;"wordpress_logged_in|wp-postpass|comment_author|woocommerce_cart_hash|woocommerce_items_in_cart")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.4-fpm-example.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;fastcgi_cache&lt;/span&gt; &lt;span class="s"&gt;EXAMPLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt; &lt;span class="mi"&gt;60m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_no_cache&lt;/span&gt; &lt;span class="nv"&gt;$skip_cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_cache_use_stale&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt; &lt;span class="s"&gt;timeout&lt;/span&gt; &lt;span class="s"&gt;updating&lt;/span&gt; &lt;span class="s"&gt;http_500&lt;/span&gt; &lt;span class="s"&gt;http_503&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-FastCGI-Cache&lt;/span&gt; &lt;span class="nv"&gt;$upstream_cache_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cookie bypass is the line that keeps this safe. Logged-in users, people who just commented, and shoppers with items in a WooCommerce cart must always hit PHP, or you'll serve one visitor's personalized page to another. The &lt;code&gt;X-FastCGI-Cache&lt;/code&gt; header gives you &lt;code&gt;HIT&lt;/code&gt;, &lt;code&gt;MISS&lt;/code&gt;, or &lt;code&gt;BYPASS&lt;/code&gt; on every response, which makes verification a one-line curl check. And &lt;code&gt;fastcgi_cache_use_stale&lt;/code&gt; is a quiet resilience win: if PHP-FPM crashes or times out, nginx serves the last known good page instead of an error.&lt;/p&gt;

&lt;p&gt;One honest admission: invalidation is the weak spot. With a pure TTL approach, a published edit can take up to an hour to appear for anonymous visitors. You can wire up purge-on-publish with the nginx cache purge module and a helper plugin, but it's fiddly. Plugin-based page caches like WP Rocket or WP Super Cache are slower at serving but far simpler to invalidate, because they live inside WordPress and know when content changes. For a frequently edited site run by non-technical editors, that trade can genuinely favor the plugin. For mostly-read sites, fastcgi_cache wins comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace WP-Cron with Real Cron
&lt;/h2&gt;

&lt;p&gt;WP-Cron is one of WordPress's oldest design compromises. Scheduled tasks don't run on a schedule; they run when a visitor happens to load a page. On a low-traffic site, that means scheduled posts publish late and backups fire whenever someone stumbles in. On a high-traffic site, it means WordPress checks the cron queue on a flood of requests, adding overhead exactly when you least want it, and long-running tasks can pile up concurrently.&lt;/p&gt;

&lt;p&gt;The fix takes two minutes. Disable the pseudo-cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp config &lt;span class="nb"&gt;set &lt;/span&gt;DISABLE_WP_CRON &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--raw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then let the actual operating system do its job. One system crontab entry, running as the site's user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* * * * * cd /var/www/example.com/current &amp;amp;&amp;amp; wp cron event run --due-now &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every minute, wp-cli checks for due events and runs them, whether or not a single visitor showed up. Scheduled posts publish on time, cleanup jobs actually run, and page requests stop carrying scheduler overhead. Running it via wp-cli also means cron tasks execute in a proper CLI context with its own memory limit, so a heavy import job can't slow down a visitor's page load. There's no scenario where the default behavior is better than this. It's the first thing we'd change on any WordPress install.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Actually Secure a WordPress Server?
&lt;/h2&gt;

&lt;p&gt;Start from an uncomfortable principle: on a single-tenant server without isolation, a WordPress compromise is a server compromise. PHP runs as the site user, so an attacker who gets code execution through a vulnerable plugin owns everything that user can touch. WordPress-specific hardening sits on top of general server hardening, not instead of it. We've written a full &lt;a href="https://deploynix.io/blog/hardening-your-deploynix-server-a-step-by-step-security-audit" rel="noopener noreferrer"&gt;step-by-step server security audit&lt;/a&gt; covering the OS layer: firewall, SSH, fail2ban. Here's the WordPress-specific list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Block xmlrpc.php Unless You Genuinely Need It
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;xmlrpc.php&lt;/code&gt; is a legacy API endpoint with two ugly properties: its &lt;code&gt;system.multicall&lt;/code&gt; method lets attackers test hundreds of password guesses in a single request, sidestepping naive rate limits, and its pingback feature has been abused for traffic amplification against third parties. Almost nothing modern needs it. The REST API replaced it years ago. The &lt;code&gt;deny all&lt;/code&gt; block in our nginx config above kills it at the edge, before PHP ever runs. The exceptions: Jetpack and some mobile or desktop publishing apps still use XML-RPC. If you need those, restrict access by source rather than leaving it open to the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate-Limit wp-login.php
&lt;/h3&gt;

&lt;p&gt;Credential stuffing against &lt;code&gt;wp-login.php&lt;/code&gt; is constant background radiation for every WordPress site. The &lt;code&gt;limit_req&lt;/code&gt; zone in the server block caps login attempts at one per second per IP with a small burst, which is invisible to humans and ruinous to dictionary attacks. Pair it with fail2ban watching for repeated 429s or auth failures, and with the boring advice that still matters most: unique strong passwords and two-factor authentication for every administrator account.&lt;/p&gt;

&lt;h3&gt;
  
  
  File Permissions and Configuration Hygiene
&lt;/h3&gt;

&lt;p&gt;The web server user needs to read WordPress files, and WordPress needs to write to &lt;code&gt;wp-content/uploads&lt;/code&gt;. It does not need write access to core files or &lt;code&gt;wp-config.php&lt;/code&gt; during normal operation. Standard practice: directories at 755, files at 644, &lt;code&gt;wp-config.php&lt;/code&gt; at 600, everything owned by the site user. Also rotate your salts and keys, the eight constants in &lt;code&gt;wp-config.php&lt;/code&gt;, whenever you suspect a compromise or offboard an administrator: rotating them invalidates every existing login cookie instantly, which is exactly what you want after an incident. &lt;code&gt;wp config shuffle-salts&lt;/code&gt; does it in one command.&lt;/p&gt;

&lt;h3&gt;
  
  
  Have an Auto-Update Policy, Not a Vibe
&lt;/h3&gt;

&lt;p&gt;Automatic minor core updates are enabled by default and you should leave them on: minor releases are security releases, and the WordPress core team's track record shipping them safely is excellent. For plugins, blanket auto-updates are riskier because plugin quality varies wildly, but for a site nobody checks weekly, an auto-updated plugin that occasionally needs a fix beats an unpatched plugin that gets exploited. Our rule of thumb: auto-update everything on low-touch sites, and use a controlled staging workflow (below) on sites where an hour of breakage costs real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups: Database Plus wp-content, and an Actual Restore Drill
&lt;/h2&gt;

&lt;p&gt;A WordPress site is exactly two things: the database, and the &lt;code&gt;wp-content&lt;/code&gt; directory holding uploads, themes, and plugins. Core files don't need backing up, since &lt;code&gt;wp core download&lt;/code&gt; recreates them bit-for-bit. So a complete backup is a &lt;code&gt;mysqldump&lt;/code&gt; (or &lt;code&gt;wp db export&lt;/code&gt;) plus an archive of &lt;code&gt;wp-content&lt;/code&gt;, shipped off the server to S3-compatible storage. Backups that live on the same disk as the site are not backups; they're copies that die with the server. Our post on &lt;a href="https://deploynix.io/blog/automated-database-backups-set-it-forget-it-sleep-well" rel="noopener noreferrer"&gt;automated database backups&lt;/a&gt; covers scheduling, retention, and encryption in depth.&lt;/p&gt;

&lt;p&gt;The part almost everyone skips: the restore drill. An untested backup is a hope, not a plan. Once a quarter, take a recent backup and restore it to a scratch server or a local environment. Import the database, unpack &lt;code&gt;wp-content&lt;/code&gt;, run &lt;code&gt;wp search-replace 'https://example.com' 'https://staging.example.com'&lt;/code&gt; to fix URLs, and click around. You're testing two things: that the backup is actually complete, and that you know the procedure well enough to execute it at 2 a.m. with adrenaline in your bloodstream. The first drill almost always surfaces a surprise. Better to find it on a calm Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Sane Update Workflow with wp-cli
&lt;/h2&gt;

&lt;p&gt;The reason "WordPress updates" have a scary reputation is that most people test them in production. With wp-cli, a staging-first workflow is fast enough to be the default rather than a special occasion.&lt;/p&gt;

&lt;p&gt;The loop looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Clone production to staging (db + wp-content), then fix URLs&lt;/span&gt;
wp db &lt;span class="nb"&gt;export &lt;/span&gt;prod.sql        &lt;span class="c"&gt;# on production&lt;/span&gt;
wp db import prod.sql        &lt;span class="c"&gt;# on staging&lt;/span&gt;
wp search-replace &lt;span class="s1"&gt;'https://example.com'&lt;/span&gt; &lt;span class="s1"&gt;'https://staging.example.com'&lt;/span&gt; &lt;span class="nt"&gt;--skip-columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;guid

&lt;span class="c"&gt;# 2. Update everything on staging&lt;/span&gt;
wp core update
wp plugin update &lt;span class="nt"&gt;--all&lt;/span&gt;
wp theme update &lt;span class="nt"&gt;--all&lt;/span&gt;

&lt;span class="c"&gt;# 3. Smoke test: homepage, login, checkout, forms, a recent post&lt;/span&gt;
&lt;span class="c"&gt;# 4. Same commands on production, with a fresh backup taken first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The smoke test doesn't need to be elaborate. Load the homepage and confirm the layout isn't broken, log in to wp-admin, submit the contact form, and if it's a store, run a test checkout. Five minutes of clicking catches the large majority of update breakage, which overwhelmingly comes from plugins rather than core. When staging is clean, production gets the same updates the same day: sitting on known-vulnerable plugin versions because "updates are scary" is how most WordPress compromises actually happen. In our experience, teams that adopt this loop stop dreading updates within a month, because the workflow turns a gamble into a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Do You Outgrow One Server?
&lt;/h2&gt;

&lt;p&gt;Later than you'd think. A tuned 4 GB VPS with the full caching stack handles traffic that would embarrass a mid-tier managed plan, because cached pages cost nginx almost nothing to serve. But there are real signals, and there's a sensible order of operations when they appear.&lt;/p&gt;

&lt;p&gt;First, offload media. &lt;code&gt;wp-content/uploads&lt;/code&gt; grows forever, bloats backups, and pins your site to one machine's disk. Moving uploads to S3-compatible object storage with an offload plugin shrinks the server's footprint and makes the site closer to stateless, which simplifies everything that follows.&lt;/p&gt;

&lt;p&gt;Second, put a CDN in front. Static assets served from edge locations cut your origin's bandwidth dramatically and improve load times for far-away visitors, and Cloudflare's free tier adds DDoS absorption that a lone VPS can't provide. Our guide to &lt;a href="https://deploynix.io/blog/using-cloudflare-with-deploynix-cdn-ddos-protection-and-ssl-configuration" rel="noopener noreferrer"&gt;using Cloudflare with Deploynix&lt;/a&gt; walks through DNS, SSL modes, and cache rules.&lt;/p&gt;

&lt;p&gt;Third, separate the database. Moving MySQL to its own server (or a managed database) frees the app server's RAM for PHP workers and page cache, and it's the prerequisite for running multiple app servers behind a load balancer later. Most sites never need that last step. The honest sequencing: exhaust caching first, then offload media, then CDN, then split the database. Horizontal scaling of WordPress itself is the final resort, not the first instinct, because each step before it is cheaper and solves a more common bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running This Stack on Deploynix
&lt;/h2&gt;

&lt;p&gt;Everything above is exactly what we automate. WordPress is a first-class project type on Deploynix, not an afterthought bolted onto a generic PHP template. When you provision a WordPress site, the provisioner installs it via wp-cli, generates the admin credentials, and stores them encrypted in your dashboard, so there's no install wizard left exposed and no password pasted into a chat thread.&lt;/p&gt;

&lt;p&gt;The server underneath is the stack from this post: nginx with the front controller pattern and static asset caching, PHP-FPM with per-site pools and per-site PHP versions (so your legacy client site can hold at 8.2 while new builds run 8.4), MySQL or MariaDB, and Valkey ready on the app server for the Redis Object Cache plugin. SSL certificates are issued and renewed automatically. Automated database backups ship to S3-compatible storage on your schedule, monitoring and alerts are included, and the security baseline (UFW firewall, fail2ban) is applied by default rather than left as an exercise. Per-site cron is managed from the UI, so the &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; pattern from earlier is a form field instead of a crontab edit over SSH. It works the same on DigitalOcean, Vultr, Linode, Hetzner, AWS, or a custom server you bring yourself.&lt;/p&gt;

&lt;p&gt;We built it this way because we host our own sites on it, and we wanted WordPress held to the same standard as our application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is PHP 8.4 safe for WordPress in 2026?
&lt;/h3&gt;

&lt;p&gt;WordPress core, yes: it has been compatible with the PHP 8.x line for years and runs measurably faster on it than on the 7.x releases the official minimum still references. Plugins are the risk. Test your specific plugin set on a staging clone before switching production, and treat any plugin that fails on 8.4 as a candidate for replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need both an object cache and a page cache?
&lt;/h3&gt;

&lt;p&gt;Yes, because they cover different traffic. The page cache serves anonymous visitors without running PHP at all, but it must bypass for logged-in users, commenters, and active carts. Those bypassed requests are exactly where the Valkey object cache earns its keep by eliminating repeat database queries. Together they cover both audiences; either alone leaves a gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I block xmlrpc.php on every site?
&lt;/h3&gt;

&lt;p&gt;Block it unless you have a confirmed dependency. Jetpack and some mobile publishing apps still use XML-RPC; almost nothing else does, since the REST API replaced it. Its &lt;code&gt;system.multicall&lt;/code&gt; method makes it a brute-force multiplier, so the safe default is &lt;code&gt;deny all&lt;/code&gt; in nginx, loosened only for the specific services that need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Valkey really replace Redis for the object cache?
&lt;/h3&gt;

&lt;p&gt;Yes. Valkey is protocol-compatible with Redis, and the standard Redis Object Cache plugin connects to it with the same &lt;code&gt;WP_REDIS_HOST&lt;/code&gt; and &lt;code&gt;WP_REDIS_PORT&lt;/code&gt; settings, no code changes required. WordPress has no idea which one is answering, and in our experience the swap is a non-event.&lt;/p&gt;

&lt;h3&gt;
  
  
  How big a server do I need for one WordPress site?
&lt;/h3&gt;

&lt;p&gt;Smaller than the hosting industry suggests. With OPcache, Valkey, and fastcgi_cache configured, a 2 GB VPS handles a typical content site with ease, and 4 GB gives comfortable headroom for WooCommerce or several sites on one box. Uncached PHP capacity is what you're sizing for, so the caching stack matters more than the droplet size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go from Here
&lt;/h2&gt;

&lt;p&gt;The gap between a $30/month managed plan and a $10 VPS isn't the hardware, and it isn't magic. It's configuration: a correct nginx server block, a tuned PHP 8.4 pool, Valkey behind the Redis Object Cache plugin, fastcgi_cache with honest bypass rules, real cron, layered security, and backups you've actually restored. None of it is exotic. All of it is the same discipline any production application gets, applied to the CMS that runs 43% of the web and too often gets none of it.&lt;/p&gt;

&lt;p&gt;Your next step: pick one site and replace WP-Cron with the system cron pattern from this post. It's a two-minute change, it's risk-free, and it'll make the rest of the stack feel approachable. If you're coming off shared hosting entirely, start with our &lt;a href="https://deploynix.io/blog/migrating-from-shared-hosting-to-deploynix-a-complete-guide" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; and build from there, one layer at a time.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>vps</category>
      <category>nginx</category>
      <category>performance</category>
    </item>
    <item>
      <title>Enabling HTTP/2 and HTTP/3 for Your Laravel App: A Practical Nginx Guide</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sat, 19 Sep 2026 11:03:02 +0000</pubDate>
      <link>https://dev.to/deploynix/enabling-http2-and-http3-for-your-laravel-app-a-practical-nginx-guide-1n0c</link>
      <guid>https://dev.to/deploynix/enabling-http2-and-http3-for-your-laravel-app-a-practical-nginx-guide-1n0c</guid>
      <description>&lt;p&gt;Open your Laravel app on a phone with two bars of signal and watch the network waterfall. On HTTP/1.1, the browser opens six TCP connections and queues everything else behind them. Your Vite bundle, your CSS, your fonts, and a dozen images line up single file, and every lost packet stalls its entire connection until TCP retransmits it. On HTTP/2, all of those requests share one connection and interleave cleanly. On HTTP/3, a lost packet delays only the one file it belonged to, and everything else keeps streaming. On a fast office connection the three waterfalls look nearly identical. On a lossy mobile connection they look like three different websites.&lt;/p&gt;

&lt;p&gt;That gap is why the protocol under your app deserves an afternoon of your time. Google's original QUIC deployment paper reported an 8% reduction in mean search latency on desktop and a measurable improvement on mobile, with the biggest wins on the slowest connections (&lt;a href="https://dl.acm.org/doi/10.1145/3098822.3098842" rel="noopener noreferrer"&gt;Google, SIGCOMM&lt;/a&gt;, 2017). Roughly a third of all websites now serve HTTP/3, and every major browser supports it (&lt;a href="https://w3techs.com/technologies/details/ce-http3" rel="noopener noreferrer"&gt;W3Techs&lt;/a&gt;, 2026). Meanwhile plenty of Laravel apps are still quietly serving HTTP/1.1 because nobody ever checked.&lt;/p&gt;

&lt;p&gt;This guide covers what each protocol version actually fixed, what that means for a Laravel application specifically, how to verify HTTP/2 is really on, and the full HTTP/3 upgrade path for nginx on Ubuntu 24.04. We'll also cover the Cloudflare shortcut that gets you HTTP/3 with zero origin changes, and we'll be honest about where the gains are real and where they're rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Did Each HTTP Version Actually Fix?
&lt;/h2&gt;

&lt;p&gt;Each protocol revision attacked one specific bottleneck. HTTP/2 fixed request queuing at the application layer. HTTP/3 fixed packet loss stalls at the transport layer. Per Cloudflare's traffic data, HTTP/3 already accounts for roughly 30% of human browser traffic on their network (&lt;a href="https://radar.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Radar&lt;/a&gt;, 2026), so this isn't speculative technology. It's what your visitors' browsers already prefer.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP/1.1: One Request at a Time
&lt;/h3&gt;

&lt;p&gt;HTTP/1.1 allows one outstanding request per connection. Browsers work around this by opening six parallel TCP connections per host, but a typical Laravel page with a JS bundle, CSS, fonts, and images needs far more than six resources. Everything beyond the first six waits in a queue. Each new connection also pays a full TCP and TLS handshake, which costs two to three round trips before a single byte of content moves.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP/2: Multiplexing, With a Catch
&lt;/h3&gt;

&lt;p&gt;HTTP/2 (2015) multiplexes every request over a single TCP connection as independent streams. The six-connection workaround disappears, header compression (HPACK) shrinks repetitive cookie and header data, and the browser can request 40 assets at once without queuing. For asset-heavy pages this was a genuine leap.&lt;/p&gt;

&lt;p&gt;The catch is that TCP itself doesn't know about streams. TCP guarantees ordered delivery of one byte sequence, so when a single packet is lost, every stream on the connection stalls until that packet is retransmitted. This is transport-layer head-of-line blocking, and it's exactly the failure mode of lossy networks. Ironically, on a bad connection HTTP/2 can occasionally perform worse than HTTP/1.1, because 1.1's six separate connections meant a lost packet only stalled one sixth of the downloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP/3: QUIC Replaces TCP
&lt;/h3&gt;

&lt;p&gt;HTTP/3 (RFC 9114, 2022) keeps HTTP/2's semantics but swaps the transport. QUIC runs over UDP and implements its own reliability, congestion control, and encryption with TLS 1.3 built into the handshake. Three properties matter in practice. First, streams are truly independent: a lost packet delays only the stream it carried. Second, the combined transport and TLS handshake completes in one round trip, and session resumption supports 0-RTT, so a returning visitor's first request rides along with the handshake itself. Third, connections are identified by a connection ID rather than an IP and port pair, so a phone switching from Wi-Fi to cellular keeps its connection alive instead of reconnecting. That's connection migration, and it's a feature TCP simply cannot offer.&lt;/p&gt;

&lt;p&gt;HTTP/1.1HTTP/2HTTP/3MultiplexingNo (browsers open ~6 parallel TCP connections)Yes, streams share one TCP connectionYes, independent QUIC streamsHead-of-line blockingYes, per connectionFixed at the HTTP layer, still present at the TCP layerEliminated; loss affects only its own streamTransportTCPTCPQUIC over UDPTLS requirementOptionalRequired in practice (no browser ever implemented cleartext h2c)Mandatory, TLS 1.3 onlyBrowser supportUniversalEffectively universal since 2015Chrome, Edge, Firefox, and Safari all support it&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does This Mean for a Laravel App Specifically?
&lt;/h2&gt;

&lt;p&gt;The protocol only accelerates the part of the page load it touches: moving bytes across the network. HTTP Archive data shows the median page now ships around 70 requests and over 2 MB of assets (&lt;a href="https://httparchive.org/reports/state-of-the-web" rel="noopener noreferrer"&gt;HTTP Archive&lt;/a&gt;, 2025), and that asset transfer is where HTTP/2 and HTTP/3 earn their keep. Your PHP execution time doesn't change by a single millisecond.&lt;/p&gt;

&lt;p&gt;Let's be specific about where each type of Laravel workload lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asset-heavy pages benefit most.&lt;/strong&gt; A marketing site, a Filament admin panel, or a Livewire app loading a Vite bundle, a CSS file, web fonts, and a grid of product images is exactly the multiplexing use case. Dozens of parallel resources over one connection, no queuing, and on mobile, loss recovery that doesn't stall the whole page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API endpoints barely notice.&lt;/strong&gt; A JSON API serving one request per round trip has nothing to multiplex. Mobile API clients get a small win from QUIC's faster handshake and connection migration, but if your endpoint takes 180ms of PHP time, the protocol is not your problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTFB is still your PHP time.&lt;/strong&gt; No protocol upgrade fixes a slow Eloquent query or an N+1 problem. If time-to-first-byte is your bottleneck, start with &lt;a href="https://deploynix.io/blog/laravel-performance-optimization-20-quick-wins-for-production-apps" rel="noopener noreferrer"&gt;our 20 quick wins for Laravel performance&lt;/a&gt; and &lt;a href="https://deploynix.io/blog/tuning-php-fpm-for-laravel-workers-memory-and-process-management" rel="noopener noreferrer"&gt;PHP-FPM tuning&lt;/a&gt; before touching the transport layer. In our experience, teams that jump straight to HTTP/3 while running unindexed queries are optimizing the wrong 5% of their load time.&lt;/p&gt;

&lt;p&gt;The honest framing: on a low-latency wired connection, upgrading from HTTP/2 to HTTP/3 is usually a marginal, single-digit-percent improvement. On high-latency or lossy mobile networks, it's frequently visible to the naked eye. If your analytics show 60% mobile traffic, this upgrade is worth more to you than to a B2B dashboard used from office fiber.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is HTTP/2 Actually Enabled on Your Server?
&lt;/h2&gt;

&lt;p&gt;Before chasing HTTP/3, verify the boring prerequisite. HTTP/2 in nginx has been mature for a decade and most modern provisioning tools enable it, yet we still see hand-configured servers negotiating HTTP/1.1 over TLS because a vhost was copied from a 2014 tutorial. Since browsers never implemented cleartext HTTP/2, it only works on your TLS-enabled vhosts, which is one more reason every site should have a certificate (&lt;a href="https://deploynix.io/blog/the-complete-guide-to-ssl-certificates-on-deploynix-lets-encrypt-wildcards-vanity-domains" rel="noopener noreferrer"&gt;our complete guide to SSL on Deploynix&lt;/a&gt; covers that end to end).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Modern Directive Form
&lt;/h3&gt;

&lt;p&gt;Nginx changed the syntax in 1.25.1. The old form put &lt;code&gt;http2&lt;/code&gt; on the listen line; that form still works but is deprecated and logs a warning. The current form is a standalone directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# ... your Laravel root, index, and PHP-FPM location blocks&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your config still says &lt;code&gt;listen 443 ssl http2;&lt;/code&gt;, it works today, but move to &lt;code&gt;http2 on;&lt;/code&gt; when you touch the file next. The old parameter form will eventually be removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying It
&lt;/h3&gt;

&lt;p&gt;Three quick checks, from fastest to most thorough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ask curl which protocol it negotiated (expect "2")&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;--http2&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_version}\n'&lt;/span&gt; https://example.com

&lt;span class="c"&gt;# Confirm the config actually loaded&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'http2|listen 443'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third check is your browser. Open DevTools, go to the Network tab, right-click the column headers, and enable the Protocol column. Load your site and look for &lt;code&gt;h2&lt;/code&gt; next to your document and assets. This column becomes your best friend again in the HTTP/3 section, where you're looking for &lt;code&gt;h3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While you're in the vhost, it's a good moment to review the rest of your server block. Our guide to &lt;a href="https://deploynix.io/blog/optimizing-nginx-for-laravel-configs-that-actually-matter" rel="noopener noreferrer"&gt;nginx configs that actually matter for Laravel&lt;/a&gt; covers gzip, static caching, and worker tuning, all of which compound with the protocol upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Enable HTTP/3 in Nginx on Ubuntu 24.04?
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips people up: Ubuntu 24.04's distro nginx is version 1.24, and QUIC support landed in the 1.25.x line before flowing into the current stable releases. The nginx that &lt;code&gt;apt install nginx&lt;/code&gt; gives you on a stock 24.04 box cannot speak HTTP/3 no matter what you put in the config. You need nginx from the official nginx.org repository (or a newer distro line), and the binary must be compiled with the &lt;code&gt;http_v3_module&lt;/code&gt;. The official docs live at &lt;a href="https://nginx.org/en/docs/quic.html" rel="noopener noreferrer"&gt;nginx.org/en/docs/quic.html&lt;/a&gt; and are worth a read alongside this section.&lt;/p&gt;

&lt;p&gt;The good news: this is a low-risk change. HTTP/3 discovery is advertisement-based, so if QUIC fails for any reason, browsers silently fall back to HTTP/2. You can't take your site down by getting HTTP/3 wrong; the worst case is that nobody uses it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install Nginx From the Official Repo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;curl gnupg2 ca-certificates lsb-release ubuntu-keyring

curl https://nginx.org/keys/nginx_signing.key | gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/share/keyrings/nginx-archive-keyring.gpg &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
http://nginx.org/packages/ubuntu &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;lsb_release &lt;span class="nt"&gt;-cs&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; nginx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/nginx.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caution from experience: the nginx.org package lays out configuration differently from Ubuntu's package. It uses &lt;code&gt;/etc/nginx/conf.d/*.conf&lt;/code&gt; and has no &lt;code&gt;sites-available&lt;/code&gt; / &lt;code&gt;sites-enabled&lt;/code&gt; convention. Back up &lt;code&gt;/etc/nginx&lt;/code&gt; before upgrading, and be prepared to either add an &lt;code&gt;include /etc/nginx/sites-enabled/*;&lt;/code&gt; line to &lt;code&gt;nginx.conf&lt;/code&gt; or move your vhosts into &lt;code&gt;conf.d&lt;/code&gt;. Do this in a maintenance window the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Confirm the Binary Has the Module
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nginx &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# any current nginx.org stable or mainline (1.25+) has QUIC support&lt;/span&gt;

nginx &lt;span class="nt"&gt;-V&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; http_v3_module
&lt;span class="c"&gt;# http_v3_module&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that grep prints nothing, your binary can't do HTTP/3, full stop. The official nginx.org packages include the module; some third-party builds don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add the QUIC Listener and Alt-Svc Header
&lt;/h3&gt;

&lt;p&gt;HTTP/3 runs alongside HTTP/2, not instead of it. Every browser's first visit arrives over TCP and negotiates h2. Your server then advertises HTTP/3 availability with an &lt;code&gt;Alt-Svc&lt;/code&gt; response header, and the browser remembers it (for &lt;code&gt;ma&lt;/code&gt; seconds, here 24 hours) and uses QUIC for subsequent requests. That's the discovery mechanism, so the header isn't optional decoration. Without it, nobody ever finds your QUIC listener.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# HTTP/3 over QUIC (UDP)&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;quic&lt;/span&gt; &lt;span class="s"&gt;reuseport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:443&lt;/span&gt; &lt;span class="s"&gt;quic&lt;/span&gt; &lt;span class="s"&gt;reuseport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# HTTP/2 over TCP (first visits and fallback)&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# QUIC requires TLS 1.3; keep 1.2 for the TCP listener&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Advertise HTTP/3 to browsers; "always" includes error responses&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Alt-Svc&lt;/span&gt; &lt;span class="s"&gt;'h3=":443"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;ma=86400'&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/example.com/current/public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.php&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# ... your usual Laravel location blocks&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details deserve emphasis. QUIC mandates TLS 1.3 (RFC 9001), so &lt;code&gt;TLSv1.3&lt;/code&gt; must be in your &lt;code&gt;ssl_protocols&lt;/code&gt; line; your existing Let's Encrypt certificates work unchanged. And &lt;code&gt;reuseport&lt;/code&gt; may appear on only one server block per address-and-port combination. If you host multiple sites on the server, put &lt;code&gt;reuseport&lt;/code&gt; in your default vhost and use plain &lt;code&gt;listen 443 quic;&lt;/code&gt; in the others, or nginx will refuse to start with a "duplicate listen options" error. That error appears at &lt;code&gt;nginx -t&lt;/code&gt; time, which is exactly why you always test before reloading.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Open UDP 443 in the Firewall
&lt;/h3&gt;

&lt;p&gt;This is the most common reason HTTP/3 "doesn't work" after a textbook-perfect config. QUIC runs over UDP, and your firewall almost certainly allows only TCP on 443. On a UFW-managed server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/udp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;443/udp ALLOW&lt;/code&gt; alongside the existing &lt;code&gt;443/tcp&lt;/code&gt; rule. If a cloud provider firewall or security group sits in front of the box (DigitalOcean Cloud Firewalls, AWS security groups, Hetzner firewalls), add UDP 443 there too. Every layer between the browser and nginx must pass UDP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Verify End to End
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# If your curl is built with HTTP/3 support:&lt;/span&gt;
curl &lt;span class="nt"&gt;--http3&lt;/span&gt; &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_version}\n'&lt;/span&gt;
&lt;span class="c"&gt;# 3&lt;/span&gt;

&lt;span class="c"&gt;# Stock Ubuntu curl usually lacks HTTP/3; at minimum confirm the advertisement:&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; alt-svc
&lt;span class="c"&gt;# alt-svc: h3=":443"; ma=86400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the real-world check: open the site in Chrome or Firefox with the DevTools Protocol column enabled, load the page, and reload it once. The first load shows &lt;code&gt;h2&lt;/code&gt; (that's the discovery visit), and the reload should show &lt;code&gt;h3&lt;/code&gt; on the document and assets. If DevTools stubbornly shows &lt;code&gt;h2&lt;/code&gt; on reloads, the diagnosis order is: Alt-Svc header present, UDP 443 open at every layer, &lt;code&gt;http_v3_module&lt;/code&gt; compiled in, and no VPN or corporate proxy on your test machine eating UDP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rollback Safety
&lt;/h3&gt;

&lt;p&gt;Worried about shipping this on a production box? Here's the reassuring part. Because discovery happens via Alt-Svc and browsers fall back to HTTP/2 within milliseconds when QUIC doesn't respond, disabling HTTP/3 is as simple as removing the &lt;code&gt;quic&lt;/code&gt; listen lines and the Alt-Svc header, then reloading nginx. Visitors who cached the advertisement quietly return to h2. No downtime, no stuck clients, no cache purge. We've found this makes HTTP/3 one of the lowest-risk infrastructure changes you can make: the failure mode is "nothing improved," not "the site broke."&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Run HTTP/3 at the Origin or at the CDN Edge?
&lt;/h2&gt;

&lt;p&gt;There are two legitimate places to terminate HTTP/3, and they aren't competing. Cloudflare enabled HTTP/3 by default years ago and now serves it for a very large share of eligible traffic (&lt;a href="https://blog.cloudflare.com/http3-usage-one-year-on/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;, 2023), which means a huge fraction of the sites your users visit already do HTTP/3 at the edge with an HTTP/2 origin behind it. That architecture is completely legitimate: browsers talk h3 to the nearest edge node, and the edge talks h2 to your nginx over a warm, long-lived connection. Zero origin changes required.&lt;/p&gt;

&lt;p&gt;CapabilityOrigin nginxCDN edge (Cloudflare in front)HTTP/2 to visitorsYes (&lt;code&gt;http2 on;&lt;/code&gt;)Yes, by defaultHTTP/3 to visitorsRequires nginx 1.25+, config, and UDP 443 openOne dashboard toggle, zero origin changesEdge-to-origin legNot applicable (direct connection)Typically HTTP/2 over a persistent connectionStatic asset proximityYour server's region onlyHundreds of PoPs near the visitorCached asset TTFBFull round trip to your serverServed from the edge, origin untouchedWho terminates TLSYour serverThe edge (plus a cert on your origin)Effort and riskRepo migration, config, firewallMinutes, trivially reversible&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: HTTP/3 at the Origin
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; No third party in the request path, no extra DNS changes, full control over TLS configuration and QUIC tuning, and end-to-end HTTP/3 for every request including uncached dynamic pages. Your Laravel HTML responses themselves ride QUIC, not just static assets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that don't want a proxy in front of their app, apps with strict data-path requirements, latency-sensitive dynamic traffic to a single region, and anyone who values understanding their full stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You own the nginx.org repo migration, the config-layout differences, and the firewall rule on every server. Your origin is still one physical location, so a visitor in Sydney hitting a server in Frankfurt pays the full round trip regardless of protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: HTTP/3 at the Cloudflare Edge
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; HTTP/3 for all visitors in about two minutes, with your origin staying on plain HTTP/2. You also pick up edge caching for static assets, which usually moves the performance needle more than the protocol itself, plus DDoS absorption. Distance is the dominant latency factor, and edges are simply closer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Anyone already using Cloudflare, teams that want the win without touching servers, and globally distributed audiences hitting a single-region origin. Our &lt;a href="https://deploynix.io/blog/using-cloudflare-with-deploynix-cdn-ddos-protection-and-ssl-configuration" rel="noopener noreferrer"&gt;Cloudflare with Deploynix guide&lt;/a&gt; walks through the full setup including SSL modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; A proxy now sits in the request path, so you'll want correct real-IP restoration for Laravel's rate limiting and logs. The edge-to-origin leg stays h2, which is fine in practice but means "HTTP/3" applies to the visitor-facing half. And you're adding a dependency you don't operate.&lt;/p&gt;

&lt;p&gt;Running both is also reasonable: h3 at the edge for visitors, h3 at the origin for the day you route traffic directly. But if you only do one, and Cloudflare is already in front of your site, the edge toggle delivers 90% of the benefit for 2% of the effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Measure Whether It Helped?
&lt;/h2&gt;

&lt;p&gt;Measure on the networks where the protocol matters, or you'll conclude it does nothing. Synthetic benchmarks against localhost or same-datacenter VMs are the classic mistake: with sub-millisecond RTT and zero packet loss, HTTP/1.1, 2, and 3 produce nearly identical numbers, because the problems these protocols solve don't exist on that link. WebPageTest's own documentation recommends testing under realistic mobile profiles precisely because throttled RTT and loss expose the differences that clean lab links hide (&lt;a href="https://docs.webpagetest.org/" rel="noopener noreferrer"&gt;WebPageTest Docs&lt;/a&gt;, 2025).&lt;/p&gt;

&lt;p&gt;A methodology we've found reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick two or three real pages, not a synthetic endpoint. Your homepage, your heaviest dashboard, and one asset-rich content page.&lt;/li&gt;
&lt;li&gt;Test before the change. Run WebPageTest with a 4G or 3G mobile profile from a location far from your server, nine runs, take the median. Save the waterfall.&lt;/li&gt;
&lt;li&gt;Ship HTTP/3, confirm &lt;code&gt;h3&lt;/code&gt; in DevTools, then re-run the identical test. Alt-Svc caching means WebPageTest's repeat-view runs are the ones most likely to use h3.&lt;/li&gt;
&lt;li&gt;Compare waterfalls, not scores. Look at connection setup time, asset download overlap, and total load time. A Lighthouse performance score can hide a 300ms network win behind unchanged PHP TTFB.&lt;/li&gt;
&lt;li&gt;Watch real-user data for two weeks. If you collect Core Web Vitals via RUM, segment LCP by connection type. The improvement concentrates in your worst-connected cohort, which averages hide.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Expect an honest result to look like this: TTFB roughly unchanged (that's PHP, remember), first-visit connection setup slightly faster, repeat-visit and lossy-network loads noticeably better, and desktop-on-fiber essentially flat. If you want to understand what your server can do under load once the network layer is sorted, our &lt;a href="https://deploynix.io/blog/how-much-traffic-can-a-5-server-handle-load-testing-laravel-on-deploynix" rel="noopener noreferrer"&gt;load test of a $5 server&lt;/a&gt; shows the methodology for the capacity side of the equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Gotchas?
&lt;/h2&gt;

&lt;p&gt;A few sharp edges are worth knowing before you flip anything on. None are dealbreakers, but each has cost someone an afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some Networks Block QUIC Entirely
&lt;/h3&gt;

&lt;p&gt;Corporate firewalls and some middleboxes drop or block UDP 443, either deliberately (so their inspection appliances can read TLS-over-TCP) or by default-deny policy. Estimates vary, but a persistent single-digit percentage of networks can't complete QUIC connections. This is precisely why the Alt-Svc fallback design matters: those users transparently stay on HTTP/2 and never notice. Don't fight it, and never build anything that assumes h3 is available.&lt;/p&gt;

&lt;h3&gt;
  
  
  UDP Amplification and Abuse
&lt;/h3&gt;

&lt;p&gt;Any UDP service invites reflection and amplification questions. QUIC's design caps how much a server may send to an unvalidated address (about three times the bytes received), which limits amplification at the protocol level. If your server faces hostile traffic, nginx's &lt;code&gt;quic_retry on;&lt;/code&gt; adds an extra address-validation round trip that forces clients to prove they own their source IP before the server commits resources. It costs new connections one RTT, so enable it under attack rather than by default. Standard nginx rate limiting still applies to the requests themselves, exactly as it does over TCP.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSockets Stay Where They Are
&lt;/h3&gt;

&lt;p&gt;Running Laravel Reverb? WebSockets bootstrap through an HTTP/1.1 Upgrade (or HTTP/2 extended CONNECT), and the HTTP/3 equivalent, WebTransport, is a different protocol that Reverb doesn't target. In practice your Reverb connections continue negotiating over h1/h2 exactly as before, and that's fine. HTTP/3 on the same vhost doesn't interfere with them. Nothing to change, nothing to worry about.&lt;/p&gt;

&lt;h3&gt;
  
  
  0-RTT Is Opt-In for a Reason
&lt;/h3&gt;

&lt;p&gt;QUIC session resumption supports 0-RTT, where a returning client sends its first request inside the handshake. Nginx keeps early data off unless you enable &lt;code&gt;ssl_early_data on;&lt;/code&gt;, because 0-RTT requests can be replayed by an attacker. If you enable it, ensure replayed GETs are harmless (they should be, if your GETs are idempotent like the HTTP spec assumes). Skipping 0-RTT entirely costs you one round trip on resumption and zero risk. That's a fine trade.&lt;/p&gt;

&lt;h3&gt;
  
  
  The reuseport Constraint on Multi-Site Servers
&lt;/h3&gt;

&lt;p&gt;Mentioned above, but it bites often enough to repeat: &lt;code&gt;reuseport&lt;/code&gt; on the &lt;code&gt;quic&lt;/code&gt; listener may appear exactly once per port across your entire config. On a server hosting six sites, five of them need &lt;code&gt;listen 443 quic;&lt;/code&gt; without the parameter. &lt;code&gt;nginx -t&lt;/code&gt; catches it, which is one more reason to never reload without testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Works on Deploynix
&lt;/h2&gt;

&lt;p&gt;Every site Deploynix provisions gets nginx with a free Let's Encrypt certificate, and TLS-enabled vhosts ship with HTTP/2 already on using the modern &lt;code&gt;http2 on;&lt;/code&gt; directive. So the first half of this guide is a verification exercise for Deploynix users rather than a setup task: open DevTools, confirm &lt;code&gt;h2&lt;/code&gt;, done. The same applies whether your server runs on DigitalOcean, Hetzner, Vultr, Linode, AWS, or a custom box.&lt;/p&gt;

&lt;p&gt;For HTTP/3 at the origin, the pieces you need are accessible without leaving the dashboard. Firewall rules are manageable per server, so adding the UDP 443 rule is a form field instead of an SSH session, and the vhost configs are editable if you want to add the &lt;code&gt;quic&lt;/code&gt; listeners and Alt-Svc header after installing a QUIC-capable nginx build. The nginx.org repo migration itself is still a hands-on step today; it changes a system package, and we'd rather you do that deliberately in a maintenance window than have a platform do it silently.&lt;/p&gt;

&lt;p&gt;The path we recommend to most users, though, is the edge route: put Cloudflare in front of your Deploynix site, and your visitors get h3 at the edge with zero origin changes, while your origin keeps its Deploynix-managed HTTP/2 and Let's Encrypt setup untouched. If you're running the load balancer server type, the same logic applies, since the balancer terminates SSL and speaks HTTP/2 to visitors while Cloudflare handles h3 in front. Server monitoring keeps an eye on the origin either way, and the protocol change is invisible to your deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does HTTP/3 require new SSL certificates?
&lt;/h3&gt;

&lt;p&gt;No. QUIC requires TLS 1.3, but your existing certificates work unchanged, including the free Let's Encrypt certificates Deploynix issues. Certificates authenticate your domain regardless of transport. The only TLS-related config change is confirming &lt;code&gt;TLSv1.3&lt;/code&gt; appears in your &lt;code&gt;ssl_protocols&lt;/code&gt; directive, which it already does in any modern config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will enabling HTTP/3 break anything if it fails?
&lt;/h3&gt;

&lt;p&gt;Practically no. Discovery works through the Alt-Svc header, so browsers try QUIC only after a successful HTTP/2 visit, and they fall back to HTTP/2 within milliseconds if UDP is blocked or the listener misbehaves. The realistic worst case is that HTTP/3 goes unused. Rollback is deleting two listen lines and one header, then reloading nginx.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Ubuntu 24.04 server refuse the quic directive?
&lt;/h3&gt;

&lt;p&gt;Because the distro ships nginx 1.24, which predates QUIC support in the 1.25.x line. You need nginx from the official nginx.org repository or another QUIC-capable build, compiled with the &lt;code&gt;http_v3_module&lt;/code&gt;. Verify with &lt;code&gt;nginx -V 2&amp;gt;&amp;amp;1 | grep http_v3_module&lt;/code&gt;; if it prints nothing, no config change will help.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is HTTP/2 to my origin plus HTTP/3 at Cloudflare a real setup or a hack?
&lt;/h3&gt;

&lt;p&gt;It's a real, widely deployed architecture. Visitors speak h3 to the nearest edge, and the edge maintains warm HTTP/2 connections to your origin, where transport-level loss barely exists on datacenter links. Cloudflare serves an enormous share of the web's HTTP/3 this way (&lt;a href="https://blog.cloudflare.com/http3-usage-one-year-on/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;, 2023). Most sites should start here.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much faster will my Laravel app actually be?
&lt;/h3&gt;

&lt;p&gt;Honest answer: it depends on your audience's networks. Google's QUIC deployment measured around 8% latency improvement on search at internet scale (&lt;a href="https://dl.acm.org/doi/10.1145/3098822.3098842" rel="noopener noreferrer"&gt;Google, SIGCOMM&lt;/a&gt;, 2017), with larger gains on poor connections. Expect visible improvement for mobile users on asset-heavy pages, near zero for wired API clients, and no change at all to your PHP TTFB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;The protocol stack under your Laravel app is one of those things you configure once and benefit from on every request afterward. Verify HTTP/2 today; it takes one curl command and there's a real chance an old vhost is quietly serving HTTP/1.1. Then pick your HTTP/3 lane based on effort tolerance: the Cloudflare toggle if you want the win this afternoon, or the nginx.org path from Step 1 above if you want it end to end at the origin. Either way the fallback behavior makes it a low-stakes experiment with a real upside for your worst-connected visitors.&lt;/p&gt;

&lt;p&gt;A good first check: open DevTools on your production site, enable the Protocol column, and see what your app is actually serving. If the answer surprises you, you now have the complete playbook. And once the transport layer is sorted, the bigger wins usually live one layer up, in the nginx configs covered in the optimization guide linked earlier.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>http3</category>
      <category>performance</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Self-Hosting Next.js in 2026: The Complete VPS Guide for Teams Leaving Vercel</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:03:04 +0000</pubDate>
      <link>https://dev.to/deploynix/self-hosting-nextjs-in-2026-the-complete-vps-guide-for-teams-leaving-vercel-5606</link>
      <guid>https://dev.to/deploynix/self-hosting-nextjs-in-2026-the-complete-vps-guide-for-teams-leaving-vercel-5606</guid>
      <description>&lt;p&gt;Every team that leaves Vercel can point to the exact invoice that did it. For months the bill sits at $20 per seat and nobody thinks about it. Then a launch goes well, image bandwidth spikes, function invocations climb, and the invoice jumps from $150 to $900 in a single billing cycle. The app didn't change. The pricing model did what pricing models do.&lt;/p&gt;

&lt;p&gt;We've watched this play out repeatedly with teams migrating to our platform, and the pattern is consistent. The trigger is rarely a single outrageous bill. It's the realization that costs now scale with success in a way the team can't predict or cap. Meanwhile, the thing they're paying for, a Node.js server behind a CDN, has never been cheaper to run yourself. A $6-14/month VPS in 2026 can comfortably host a dozen containerized apps, and open-source tooling has largely closed the deployment-UX gap that justified managed platforms in the first place (&lt;a href="https://www.digitalocean.com/resources/articles/vercel-alternatives" rel="noopener noreferrer"&gt;DigitalOcean's Vercel-alternatives roundup&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This guide is the complete migration path: when leaving makes sense (and when it doesn't), what you must replace item by item, the standalone build, the nginx layer, ISR caching on a single box, atomic deploys with rollback, and the scaling path beyond one server. It's written by people who run this stack in production, not from a pricing page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- Self-hosting Next.js makes sense for steady-traffic, bandwidth-heavy, or compliance-bound apps; less so for edge-function-heavy stacks. - &lt;code&gt;output: 'standalone'&lt;/code&gt; produces a self-contained &lt;code&gt;server.js&lt;/code&gt; you run under a process manager behind nginx. - Vercel Enterprise contracts run a median of roughly $45,000/year (makerkit); a capable VPS costs under $200/year at the time of writing. - ISR cache persistence and image optimization are the two details most migrations get wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When Does Leaving Vercel Make Sense?
&lt;/h2&gt;

&lt;p&gt;The honest answer: when your costs have become predictable in the wrong direction. If you can look at your traffic graph and your Vercel invoice and draw a straight line between them, self-hosting converts a variable cost into a fixed one. Vercel Enterprise contracts run a median of roughly $45,000 per year, which is exactly why regulated and cost-sensitive teams keep landing on self-hosting (&lt;a href="https://makerkit.dev/blog/tutorials/best-hosting-nextjs" rel="noopener noreferrer"&gt;makerkit's Next.js hosting guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Four situations make the move clearly worthwhile:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steady, predictable traffic.&lt;/strong&gt; Vercel's elasticity is valuable when your traffic is spiky and unpredictable. If your app serves roughly the same audience every day, you're paying an elasticity premium for elasticity you don't use. A fixed-size VPS handles fixed-size traffic at a fixed price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bandwidth-heavy workloads.&lt;/strong&gt; Image galleries, video-adjacent apps, file downloads, anything where egress dominates. Platform bandwidth pricing is where invoices go vertical. On a VPS, providers like Hetzner include 20 TB of traffic on plans that cost less than a lunch, at the time of writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data residency and compliance.&lt;/strong&gt; If your contracts or regulators require data to live in a specific country, or you need to answer "where exactly does this run?" with a server you control, a VPS in the right region is the simple answer. We covered the same dynamic for backend teams in our piece on &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation from AWS to a $10 VPS&lt;/a&gt;, and the logic transfers directly to frontend hosting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost ceilings.&lt;/strong&gt; Some teams simply need a number that cannot surprise them. A VPS bill is that number.&lt;/p&gt;

&lt;h3&gt;
  
  
  When It Doesn't Make Sense
&lt;/h3&gt;

&lt;p&gt;Be equally honest about the other side. If your app leans heavily on Vercel-specific edge primitives, dozens of edge functions, edge middleware doing per-request geo logic, edge config, you'll spend real engineering time rebuilding those behaviors, and the result will run in one region instead of hundreds. That trade can be a net loss.&lt;/p&gt;

&lt;p&gt;And if you're a two-person team with zero appetite for operations, think hard. Modern tooling has made self-hosting dramatically easier, but "easier" is not "zero." Someone still owns the server. If nobody on the team wants that pager, even in its mild modern form, the Vercel premium is buying something real: the ability to never think about infrastructure. That's a legitimate purchase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are You Actually Replacing?
&lt;/h2&gt;

&lt;p&gt;Vercel is not magic. It's a well-integrated bundle of components, each of which has a self-hosted equivalent. Here's the honest mapping:&lt;/p&gt;

&lt;p&gt;Vercel feature&lt;/p&gt;

&lt;p&gt;Self-hosted equivalent&lt;/p&gt;

&lt;p&gt;Effort&lt;/p&gt;

&lt;p&gt;Serverless/edge functions&lt;/p&gt;

&lt;p&gt;Node.js server routes behind nginx (API routes run in the same process)&lt;/p&gt;

&lt;p&gt;Low for API routes, high for true edge logic&lt;/p&gt;

&lt;p&gt;ISR / on-demand revalidation&lt;/p&gt;

&lt;p&gt;Next.js file-system cache (persist &lt;code&gt;.next/cache&lt;/code&gt; across releases) or a custom cache handler backed by Redis&lt;/p&gt;

&lt;p&gt;Medium&lt;/p&gt;

&lt;p&gt;Image optimization&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; with &lt;code&gt;sharp&lt;/code&gt; installed on the server, or offload to Cloudflare&lt;/p&gt;

&lt;p&gt;Low&lt;/p&gt;

&lt;p&gt;Preview deployments&lt;/p&gt;

&lt;p&gt;Staging sites per branch (a second site on the same or another server)&lt;/p&gt;

&lt;p&gt;Medium&lt;/p&gt;

&lt;p&gt;Global edge network / CDN&lt;/p&gt;

&lt;p&gt;Cloudflare (or another CDN) in front of your origin&lt;/p&gt;

&lt;p&gt;Low&lt;/p&gt;

&lt;p&gt;Automatic SSL&lt;/p&gt;

&lt;p&gt;Let's Encrypt via certbot, or your platform handles it&lt;/p&gt;

&lt;p&gt;Low&lt;/p&gt;

&lt;p&gt;Analytics / Speed Insights&lt;/p&gt;

&lt;p&gt;Self-hosted Plausible, Umami, or Sentry for performance&lt;/p&gt;

&lt;p&gt;Low&lt;/p&gt;

&lt;p&gt;Zero-config Git deploys&lt;/p&gt;

&lt;p&gt;A deploy script or a platform like Deploynix pulling from Git&lt;/p&gt;

&lt;p&gt;Low to medium&lt;/p&gt;

&lt;p&gt;Two rows deserve emphasis because they're where migrations get sloppy: ISR cache persistence and image optimization. We'll spend full sections on both.&lt;/p&gt;

&lt;h3&gt;
  
  
  What You Lose, With No Sugarcoating
&lt;/h3&gt;

&lt;p&gt;The one thing you cannot replicate on a single VPS is Vercel's global edge network. Your server lives in one region. A user in Sydney hitting your Frankfurt VPS pays a real latency tax on the first byte of every server-rendered page.&lt;/p&gt;

&lt;p&gt;The standard mitigation is Cloudflare in front of your origin. Static assets, images, and cached HTML get served from Cloudflare's POPs worldwide, which recovers most of the perceived performance for most apps. Server-rendered, uncacheable pages still make the round trip to your origin. For a typical SaaS dashboard where users are concentrated in one or two regions, this is a non-issue. For a globally distributed content site with heavy SSR, it's a genuine trade-off you should measure before committing. We wrote up the full setup in &lt;a href="https://deploynix.io/blog/using-cloudflare-with-deploynix-cdn-ddos-protection-and-ssl-configuration" rel="noopener noreferrer"&gt;using Cloudflare with Deploynix for CDN, DDoS protection, and SSL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does It Actually Cost?
&lt;/h2&gt;

&lt;p&gt;Numbers below are at the time of writing (August 2026) and assume a production app doing a few hundred thousand requests per month with moderate image traffic.&lt;/p&gt;

&lt;p&gt;Item&lt;/p&gt;

&lt;p&gt;Vercel Pro&lt;/p&gt;

&lt;p&gt;Self-hosted VPS&lt;/p&gt;

&lt;p&gt;Base platform&lt;/p&gt;

&lt;p&gt;$20/seat/month&lt;/p&gt;

&lt;p&gt;$0&lt;/p&gt;

&lt;p&gt;Compute&lt;/p&gt;

&lt;p&gt;Included, then usage-billed&lt;/p&gt;

&lt;p&gt;$12-24/month (2-4 vCPU VPS)&lt;/p&gt;

&lt;p&gt;Bandwidth&lt;/p&gt;

&lt;p&gt;1 TB included, ~$0.15/GB after&lt;/p&gt;

&lt;p&gt;1-20 TB included with the VPS&lt;/p&gt;

&lt;p&gt;Image optimization&lt;/p&gt;

&lt;p&gt;Usage-billed after included quota&lt;/p&gt;

&lt;p&gt;$0 (sharp) or $0 (Cloudflare free tier)&lt;/p&gt;

&lt;p&gt;SSL&lt;/p&gt;

&lt;p&gt;Included&lt;/p&gt;

&lt;p&gt;$0 (Let's Encrypt)&lt;/p&gt;

&lt;p&gt;Preview environments&lt;/p&gt;

&lt;p&gt;Included&lt;/p&gt;

&lt;p&gt;$0-12/month (staging site, optionally a second small VPS)&lt;/p&gt;

&lt;p&gt;Analytics&lt;/p&gt;

&lt;p&gt;Usage-billed add-on&lt;/p&gt;

&lt;p&gt;$0-9/month (self-hosted Plausible/Umami)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typical monthly total (5-person team)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;$100-400+, usage-dependent&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;$15-40, fixed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The absolute numbers matter less than the shape. The Vercel column scales with usage and team size. The VPS column is flat until you deliberately resize it. For teams doing serious traffic, the gap widens: the median Vercel Enterprise contract of ~$45,000/year buys roughly two decades of a well-specced dedicated server.&lt;/p&gt;

&lt;p&gt;What the table doesn't show is your time. Budget a day or two for the initial migration and an hour or two per month for maintenance (mostly unattended if you automate updates). If your loaded engineering cost makes those hours worth more than the savings, that's your answer, and it's a fine one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build Next.js for a VPS?
&lt;/h2&gt;

&lt;p&gt;Next.js ships a build mode designed exactly for this: &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/output" rel="noopener noreferrer"&gt;standalone output&lt;/a&gt;. Instead of requiring your full &lt;code&gt;node_modules&lt;/code&gt; tree (often 500 MB+) on the production server, standalone mode traces exactly which files your server code actually imports and copies only those into &lt;code&gt;.next/standalone&lt;/code&gt;, alongside a self-contained &lt;code&gt;server.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Enable it in &lt;code&gt;next.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="cm"&gt;/** @type {import('next').NextConfig} */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;next build&lt;/code&gt;, the &lt;code&gt;.next/standalone&lt;/code&gt; directory contains everything needed to run the app: a minimal &lt;code&gt;node_modules&lt;/code&gt;, your compiled server code, and &lt;code&gt;server.js&lt;/code&gt; as the entry point. Two directories are deliberately excluded and must be placed manually: &lt;code&gt;public/&lt;/code&gt; and &lt;code&gt;.next/static/&lt;/code&gt;. That exclusion is intentional, because the docs assume you'll serve those through a CDN or web server. We will, via nginx, in the next section.&lt;/p&gt;

&lt;p&gt;Running the server is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HOSTNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1 &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3000 &lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind to &lt;code&gt;127.0.0.1&lt;/code&gt;, not &lt;code&gt;0.0.0.0&lt;/code&gt;. Nothing should reach the Node process except nginx on the same machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick a Node LTS and Pin It
&lt;/h3&gt;

&lt;p&gt;Run the current LTS (Node 22 at the time of writing) and pin the major version in &lt;code&gt;package.json&lt;/code&gt; engines and in your server provisioning. The failure mode you're avoiding: your CI builds on Node 22, your server runs Node 20, and you debug a native-module mismatch at 11 pm. Same major version everywhere, always.&lt;/p&gt;

&lt;h3&gt;
  
  
  The NEXT_PUBLIC_ Pitfall
&lt;/h3&gt;

&lt;p&gt;This one bites almost every first migration, so read it twice. Next.js has two classes of environment variables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-side variables (&lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;API_SECRET&lt;/code&gt;) are read at runtime. Change them, restart the process, done.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables are inlined into the JavaScript bundle at build time. They are string-replaced into your client code during &lt;code&gt;next build&lt;/code&gt;. Changing them on the server after the build does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Vercel this distinction is invisible because every env change triggers a rebuild. On your VPS, it means &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values must be present in the environment where &lt;code&gt;next build&lt;/code&gt; runs, and changing one requires a new build, not a restart. Structure your deploy script accordingly, and resist the temptation to put anything in &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; that isn't truly public. It ships to every browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Keep the Process Running?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;node server.js&lt;/code&gt; in a tmux session is not a production strategy. You need a process manager that starts the app on boot, restarts it on crash, and captures logs. Systemd works; PM2 works; we'll show Supervisor because its config is the most readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:nextjs-myapp]&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node server.js&lt;/span&gt;
&lt;span class="py"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/myapp/current/.next/standalone&lt;/span&gt;
&lt;span class="py"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;NODE_ENV="production",HOSTNAME="127.0.0.1",PORT="3000"&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploy&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;startretries&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/log/myapp/next.out.log&lt;/span&gt;
&lt;span class="py"&gt;stderr_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/log/myapp/next.err.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;directory&lt;/code&gt; points at &lt;code&gt;current/&lt;/code&gt;, a symlink we'll create in the deploy section. That symlink is the hinge that makes atomic releases work. Also note &lt;code&gt;stopwaitsecs=10&lt;/code&gt;: Next.js handles SIGTERM gracefully, finishing in-flight requests before exiting, and you want to give it those seconds rather than SIGKILL-ing mid-request.&lt;/p&gt;

&lt;p&gt;Runtime environment variables can live in the Supervisor stanza for simple setups, or in an env file loaded by a wrapper script for anything with secrets. Either way, they're runtime-only. The &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values were already baked in at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nginx Layer: Where the Performance Lives
&lt;/h2&gt;

&lt;p&gt;nginx sits in front of the Node process and does three jobs: TLS termination, reverse proxying dynamic requests, and serving static assets directly so Node never sees them. That last job matters more than people expect. Every request nginx answers from disk is a request your Node event loop doesn't handle.&lt;/p&gt;

&lt;p&gt;Here's a production-ready server block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;myapp.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;25m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip_types&lt;/span&gt; &lt;span class="nc"&gt;text/css&lt;/span&gt; &lt;span class="nc"&gt;application/javascript&lt;/span&gt; &lt;span class="nc"&gt;application/json&lt;/span&gt; &lt;span class="nc"&gt;image/svg&lt;/span&gt;&lt;span class="s"&gt;+xml&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip_min_length&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Immutable build assets: serve from disk, cache for a year&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/_next/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp/current/.next/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=31536000,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/public/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp/current/public/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;7d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Everything else goes to the Node process&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;"upgrade"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few deliberate choices worth explaining:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The **&lt;code&gt;/_next/static/&lt;/code&gt;&lt;/strong&gt; location block** is the payoff for standalone mode's "bring your own static serving" design. Next.js fingerprints every file in that directory with a content hash, so the files are immutable by construction. &lt;code&gt;max-age=31536000, immutable&lt;/code&gt; tells browsers to never revalidate them. Combined with Cloudflare in front, these assets get served from edge POPs and your origin barely sees them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Upgrade headers&lt;/strong&gt; (&lt;code&gt;proxy_http_version 1.1&lt;/code&gt; plus the &lt;code&gt;Upgrade&lt;/code&gt;/&lt;code&gt;Connection&lt;/code&gt; pair) enable WebSocket passthrough. You need this for Next.js HMR if you ever proxy a dev server, and for any production WebSocket usage in the app itself. Leaving them out produces the classic symptom of connections that open and instantly die.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;client_max_body_size 25m&lt;/code&gt; because nginx's 1 MB default will 413 your first file-upload feature and you'll spend an hour blaming Next.js.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on tuning, our guide to &lt;a href="https://deploynix.io/blog/optimizing-nginx-for-laravel-configs-that-actually-matter" rel="noopener noreferrer"&gt;nginx configs that actually matter&lt;/a&gt; is Laravel-flavored but 80% of it, buffers, gzip levels, keepalive, applies unchanged here.&lt;/p&gt;

&lt;p&gt;Enable brotli too if your nginx build includes the module; it compresses JS bundles 15-20% smaller than gzip. If it doesn't, don't recompile nginx just for this. Cloudflare applies brotli at the edge anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does ISR Work on a Single Server?
&lt;/h2&gt;

&lt;p&gt;Incremental Static Regeneration is the feature people assume won't survive off Vercel. It survives fine, with one operational detail you must get right.&lt;/p&gt;

&lt;p&gt;Self-hosted Next.js implements ISR with a file-system cache in &lt;code&gt;.next/cache&lt;/code&gt;. Rendered pages and their revalidation timestamps live there. &lt;code&gt;revalidate&lt;/code&gt; intervals, on-demand revalidation via &lt;code&gt;revalidatePath&lt;/code&gt; and &lt;code&gt;revalidateTag&lt;/code&gt;, all of it works out of the box on a single Node process. No configuration required.&lt;/p&gt;

&lt;p&gt;The detail that bites: &lt;strong&gt;the cache directory must persist across deploys.&lt;/strong&gt; If every release ships a fresh &lt;code&gt;.next/cache&lt;/code&gt;, you throw away every regenerated page on deploy. The first visitor to each page after a release eats a full render, and if you deploy often, ISR effectively stops existing. Worse, &lt;code&gt;fetch()&lt;/code&gt; data cache entries vanish too, so your upstream APIs get hammered right after each deploy, exactly when you least want surprises.&lt;/p&gt;

&lt;p&gt;The fix follows the same shared-storage pattern used in Capistrano-style deployments for years: keep the cache outside the release directory and symlink it in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/www/myapp/
├── releases/
│   ├── 20260801121500/
│   └── 20260802093000/
├── shared/
│   └── next-cache/        # persists forever
└── current -&amp;gt; releases/20260802093000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each release symlinks &lt;code&gt;.next/cache&lt;/code&gt; (or specifically &lt;code&gt;.next/cache/fetch-cache&lt;/code&gt; plus the ISR page cache, depending on your Next version) to &lt;code&gt;shared/next-cache&lt;/code&gt;. Deploys swap the &lt;code&gt;current&lt;/code&gt; symlink; the cache never moves. One caveat: after a build that changes a page's structure, Next's build ID changes and stale entries are ignored safely, so you don't need to invalidate manually.&lt;/p&gt;

&lt;p&gt;If you later scale to multiple Node instances, the file-system cache stops being shared state, and you'll want Next's custom &lt;code&gt;cacheHandler&lt;/code&gt; config pointed at Redis. On one server, the file system is simpler and fast. Don't add Redis before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Image Optimization?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; on Vercel silently uses their image optimization service, and it's a top-three source of surprise billing. Self-hosted, you have two good options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: optimize on the server.&lt;/strong&gt; Install &lt;code&gt;sharp&lt;/code&gt; as a production dependency (&lt;code&gt;npm install sharp&lt;/code&gt;) and Next.js uses it automatically for on-the-fly resizing and WebP/AVIF conversion. Sharp is fast native code; a 2-vCPU VPS handles typical image workloads without drama. Optimized images land in that same &lt;code&gt;.next/cache&lt;/code&gt; directory, which is another reason the shared-cache setup above matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: offload to the CDN.&lt;/strong&gt; If you're already putting Cloudflare in front (you should), Cloudflare's image resizing or simply caching your sharp-generated variants at the edge means each unique image size gets rendered on your origin exactly once, then served from POPs forever. For image-heavy sites this combination, sharp for generation, Cloudflare for delivery, gets you 95% of Vercel's image pipeline at a fixed cost.&lt;/p&gt;

&lt;p&gt;One warning: configure &lt;code&gt;images.remotePatterns&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt; strictly. An open image optimizer is a free resizing service for the entire internet, and people will find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploys: Atomic Releases, Zero Downtime, Rollback
&lt;/h2&gt;

&lt;p&gt;This is where "run node on a VPS" becomes an actual production workflow. The requirements are simple to state: a deploy either fully succeeds or fully doesn't, users never see a half-deployed app, and rollback takes seconds.&lt;/p&gt;

&lt;p&gt;The releases/current-symlink structure from the ISR section gives us all three. Here's a complete deploy script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;APP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/myapp
&lt;span class="nv"&gt;RELEASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RELEASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;/releases/&lt;span class="nv"&gt;$RELEASE&lt;/span&gt;

&lt;span class="c"&gt;# 1. Fetch code&lt;/span&gt;
git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 git@github.com:acme/myapp.git &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Build-time env (NEXT_PUBLIC_ vars must exist NOW, not later)&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;/shared/.env.build .env.production

&lt;span class="c"&gt;# 3. Clean, reproducible install and build&lt;/span&gt;
npm ci
npx next build

&lt;span class="c"&gt;# 4. Assemble the standalone runtime&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; public .next/standalone/public
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; .next/static .next/standalone/.next/static

&lt;span class="c"&gt;# 5. Link the persistent ISR/image cache into the new release&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .next/standalone/.next/cache
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;/shared/next-cache .next/standalone/.next/cache

&lt;span class="c"&gt;# 6. Atomic switch: this line is the deploy&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;/current

&lt;span class="c"&gt;# 7. Graceful restart under the process manager&lt;/span&gt;
supervisorctl restart nextjs-myapp

&lt;span class="c"&gt;# 8. Keep the last 5 releases for instant rollback&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;/releases &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1t&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +6 | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomicity lives in step 6. &lt;code&gt;ln -sfn&lt;/code&gt; replaces the symlink in a single filesystem operation; there is no moment where &lt;code&gt;current&lt;/code&gt; points at a half-built release. If the build fails at step 3, the script exits (&lt;code&gt;set -e&lt;/code&gt;) and production never noticed a deploy was attempted.&lt;/p&gt;

&lt;p&gt;Rollback is the same operation pointed backwards: &lt;code&gt;ln -sfn&lt;/code&gt; to the previous release directory, restart, done. Under ten seconds, no rebuild.&lt;/p&gt;

&lt;p&gt;The restart in step 7 causes a brief blip on a bare single process: Supervisor stops the old process gracefully, then starts the new one, so there's a window of a second or two where nginx gets connection refused. For most apps that's acceptable (nginx can even be configured to retry). For true zero-downtime you start the new process on an alternate port, health-check it, flip nginx's upstream, then drain the old one. That's the dance a deployment platform automates for you, and it's tedious enough by hand that it's usually the point where teams stop enjoying the DIY approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preview Deploys Become Staging Sites
&lt;/h3&gt;

&lt;p&gt;Vercel's per-PR preview URLs are genuinely excellent, and the self-hosted equivalent is more manual: a staging site. Same server (or a cheap second one), same deploy script pointed at a &lt;code&gt;staging&lt;/code&gt; branch, &lt;code&gt;staging.myapp.com&lt;/code&gt; in nginx, HTTP basic auth in front if the content is sensitive. You won't get a URL per pull request without building tooling for it. Most teams find one persistent staging environment plus local dev covers 90% of what they actually used previews for. Be honest with yourself about whether your team is in the other 10%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vercel vs. Self-Hosted: The Fair Comparison
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vercel&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; The developer experience is the best in the industry, and that's not faint praise. Push to a branch, get a preview URL, merge, it's live globally in two minutes. The edge network is real infrastructure with real POPs, not marketing. Framework integration is perfect by definition, since the same company builds Next.js. Zero operational burden, ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Small teams with no ops appetite, spiky or unpredictable traffic, apps that genuinely exploit edge functions and middleware, agencies shipping many small sites, and anyone whose engineering time is worth far more than the hosting delta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Costs scale with usage and success, and the ceiling is high: Enterprise medians around $45K/year at the time of writing. Bandwidth and image optimization are the classic bill inflators. You're also accepting vendor coupling; features like edge config and their KV don't travel with you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosted VPS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Fixed, predictable cost that's often 5-10x lower at moderate scale. Full control: your nginx, your Node flags, your data location, your log retention. No usage anxiety. The stack (nginx + Node + a process manager) is boring, documented, twenty-years-mature technology that any Linux-literate engineer can debug at 2 am.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Steady traffic, bandwidth-heavy apps, data-residency requirements, cost-capped teams, and anyone already running a VPS for their backend who can co-locate the frontend. If you're pairing a Next.js frontend with a Laravel API, our walkthrough on &lt;a href="https://deploynix.io/blog/deploying-a-laravel-api-separate-frontend-nextjs-nuxt-spa-on-deploynix" rel="noopener noreferrer"&gt;deploying a Laravel API with a separate Next.js or Nuxt frontend&lt;/a&gt; covers that exact topology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You own the server: updates, monitoring, capacity. Single-region origin latency for far-away users (mitigated but not eliminated by Cloudflare). Preview environments are staging sites, not per-PR URLs. Edge-function-heavy apps need rearchitecting. The first migration costs a day or two of real engineering time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring and the Scaling Path
&lt;/h2&gt;

&lt;p&gt;Self-hosting without monitoring isn't self-hosting, it's gambling. The minimum viable setup: uptime checks on the public URL, process-level checks (is &lt;code&gt;node server.js&lt;/code&gt; running, is memory stable), and disk/CPU/RAM alerts on the VPS itself. Next.js apps have one specific failure mode worth watching: slow memory growth from unbounded in-memory caching or leaky third-party SDKs. A memory graph with an alert at 85% catches it days before the OOM killer does.&lt;/p&gt;

&lt;p&gt;The scaling path is refreshingly boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Vertical first. A standalone Next.js server is efficient; a 4-vCPU/8 GB VPS serves a surprising amount of SSR traffic. Resizing a VPS is a five-minute reboot. Our load-testing writeup on how much traffic a $5 server can actually handle used Laravel as the subject, but the headline finding, that small servers are wildly underestimated, applies at least as strongly to Node.&lt;/li&gt;
&lt;li&gt;Cloudflare aggressively. Before adding servers, make sure cacheable responses are actually being cached at the edge. Many "we need to scale" moments are really "we're rendering the same page 10,000 times" moments.&lt;/li&gt;
&lt;li&gt;Horizontal when vertical runs out. A load balancer in front of two or more Node instances. Here the ISR caveat returns: the file-system cache is per-machine, so two instances will regenerate pages independently and can serve briefly inconsistent versions. At this point, wire up Next's &lt;code&gt;cacheHandler&lt;/code&gt; to Redis so all instances share one cache. This is the single biggest architectural difference between one server and several, and knowing it in advance makes the transition a config change instead of an incident.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Running Next.js on Deploynix
&lt;/h2&gt;

&lt;p&gt;Everything above is exactly what Deploynix automates, so here's the concrete version of this guide on our platform. Next.js is one of 14 first-class project types (alongside Nuxt and SvelteKit for SSR), not a generic "Node app" you configure from scratch. When you create a Next.js site, it runs as a managed Node process with a sensible default start command and port, behind an nginx reverse proxy configured the way this article describes, static asset serving and proxy headers included.&lt;/p&gt;

&lt;p&gt;Deploys pull from GitHub, GitLab, or Bitbucket and use the atomic release structure from the deploy section: zero-downtime switches, one-click rollback to a previous release, shared directories for cache persistence. SSL is free and automatic, monitoring and alerts are built in, and daemons and queue workers are managed the same way the Next process is, so a background job runner sits next to your app without hand-writing Supervisor stanzas. You bring the VPS from DigitalOcean, Vultr, Linode, Hetzner, AWS, or any custom server with root access; we provision it. When you outgrow one box, a load balancer is its own server type.&lt;/p&gt;

&lt;p&gt;We built it because we got tired of maintaining the bash script above across a dozen servers. If you're coming from a managed platform, the mental model transfer is small; teams arriving from Railway, Render, or Fly.io will find the moving parts familiar, and we wrote a dedicated guide on &lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt;migrating from Railway, Render, or Fly.io to a VPS&lt;/a&gt; covering that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I lose ISR and on-demand revalidation by leaving Vercel?
&lt;/h3&gt;

&lt;p&gt;No. ISR works out of the box in self-hosted Next.js via a file-system cache, including &lt;code&gt;revalidatePath&lt;/code&gt; and &lt;code&gt;revalidateTag&lt;/code&gt;. The one requirement is persisting &lt;code&gt;.next/cache&lt;/code&gt; across deploys with a shared directory. On multiple servers, switch to a Redis-backed custom cache handler so instances share one cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VPS size should I start with for a production Next.js app?
&lt;/h3&gt;

&lt;p&gt;At the time of writing, a 2-vCPU/4 GB VPS ($12-24/month) comfortably runs a typical SSR app doing hundreds of thousands of requests per month, especially with Cloudflare caching in front. Start there, watch memory and CPU for two weeks, and resize vertically before considering anything more complex.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I keep using next/image without Vercel?
&lt;/h3&gt;

&lt;p&gt;Yes. Install &lt;code&gt;sharp&lt;/code&gt; as a production dependency and Next.js optimizes images on your server automatically. Put Cloudflare in front so each generated variant is cached at the edge and rendered only once. Lock down &lt;code&gt;images.remotePatterns&lt;/code&gt; so strangers can't use your optimizer as a free service.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I replicate preview deployments?
&lt;/h3&gt;

&lt;p&gt;The practical self-hosted equivalent is a persistent staging site: same deploy pipeline, &lt;code&gt;staging&lt;/code&gt; branch, its own subdomain, basic auth if needed. Per-PR preview URLs are possible but require custom tooling. Most teams find one staging environment covers the real use case, which is "let a non-developer click through the change before merge."&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a single-region VPS actually fast enough for global users?
&lt;/h3&gt;

&lt;p&gt;For static assets and cacheable pages behind Cloudflare, yes, they're served from edge POPs regardless of where your origin lives. Uncached SSR requests pay the round trip to your region, typically 80-250 ms extra for far-away users. If most of your audience is in one or two regions, it's rarely noticeable. Measure with real-user monitoring before assuming either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;The short version of 3,000 words: leaving Vercel in 2026 is a well-trodden path, not a leap. The build (&lt;code&gt;output: 'standalone'&lt;/code&gt;), the runtime (a process manager), the front door (nginx plus Cloudflare), and the deploys (atomic releases with a persistent cache) are all solved problems with boring, reliable answers. What you give up is a world-class DX and a global edge; what you get back is control and a hosting bill that never surprises you again. Whether that trade is right depends on your traffic shape and your team's ops appetite, and now you have the full picture to decide.&lt;/p&gt;

&lt;p&gt;Your next step: run the numbers. Pull your last three Vercel invoices, put them next to a $24/month VPS, and price your own migration day honestly. If the math says move, spin up a test server, follow the build and nginx sections above against a staging domain, and see the whole stack working before you touch production DNS. The first deploy takes an afternoon. Every one after that takes a git push.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>selfhosting</category>
      <category>vps</category>
      <category>nginx</category>
    </item>
    <item>
      <title>Expand-and-Contract: Safe Database Migrations for Zero-Downtime Laravel Deploys</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:03:02 +0000</pubDate>
      <link>https://dev.to/deploynix/expand-and-contract-safe-database-migrations-for-zero-downtime-laravel-deploys-3jde</link>
      <guid>https://dev.to/deploynix/expand-and-contract-safe-database-migrations-for-zero-downtime-laravel-deploys-3jde</guid>
      <description>&lt;p&gt;Earlier this year we helped a team debug a deploy that should have been boring. They renamed &lt;code&gt;users.name&lt;/code&gt; to &lt;code&gt;users.full_name&lt;/code&gt;, updated every reference in the codebase, wrote a one-line migration with &lt;code&gt;renameColumn()&lt;/code&gt;, and shipped. Their deploy pipeline was genuinely zero-downtime: new release directory, atomic symlink flip, graceful FPM reload. And yet their app returned HTTP 500 for roughly 40 seconds, right in the middle of business hours. Every request that touched a user record died with &lt;code&gt;Column not found: 1054 Unknown column 'name'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing in their pipeline was broken. The migration ran before the symlink flipped, which meant the old release, the one still serving live traffic, spent 40 seconds querying a column that no longer existed. The deploy tooling did exactly what it promised. The migration was the problem, because it assumed code and schema change at the same instant. They never do.&lt;/p&gt;

&lt;p&gt;That gap between "schema changed" and "code changed" is not a bug you can fix with better tooling. It's a structural property of any deploy that keeps serving traffic while it works. The only reliable answer is a discipline called expand-and-contract (sometimes "parallel change"): make every schema change in phases, so that every migration is compatible with both the release before it and the release after it. This post walks through the pattern end to end, with real Laravel migration code, two worked examples, the places where you can honestly skip the ceremony, and the guardrails that catch dangerous migrations before they reach production.&lt;/p&gt;

&lt;p&gt;If you're new to zero-downtime deployment itself, our primer on &lt;a href="https://deploynix.io/blog/what-is-zero-downtime-deployment-and-why-does-your-laravel-app-need-it" rel="noopener noreferrer"&gt;what zero-downtime deployment is and why your Laravel app needs it&lt;/a&gt; covers the foundations. Here we're going one layer deeper, into the part of the deploy that atomic symlinks can't protect: your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does "Just Run the Migration" Break Under Zero-Downtime Deploys?
&lt;/h2&gt;

&lt;p&gt;A zero-downtime deploy never stops serving traffic, which means there is always a window where two versions of your application coexist against one database. Either the new schema serves old code (migrations run before the release flips) or the old schema serves new code (migrations run after). There is no ordering that avoids the overlap entirely.&lt;/p&gt;

&lt;p&gt;Here's the timeline for a typical release-directory deploy, the same sequence we described in &lt;a href="https://deploynix.io/blog/the-anatomy-of-a-zero-downtime-deploy-what-happens-in-those-15-seconds" rel="noopener noreferrer"&gt;the anatomy of a zero-downtime deploy&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;TimeDeploy stepCode serving trafficSchema in the databaseT+0sNew release directory created, code clonedOld releaseOldT+4s&lt;code&gt;composer install&lt;/code&gt;, config cachedOld releaseOldT+9s*&lt;em&gt;Migrations run&lt;/em&gt;* (deploy hook)&lt;strong&gt;Old release&lt;/strong&gt;&lt;strong&gt;New&lt;/strong&gt;T+14sSymlink flips to new release, FPM reloadsNew releaseNewT+14s onwardIn-flight requests on old workers drain*&lt;em&gt;Old release (briefly)&lt;/em&gt;*New&lt;/p&gt;

&lt;p&gt;Look at T+9s through the drain window. The old release is live against the new schema for several seconds, longer if migrations are slow, and longer still on multi-server fleets where the flip isn't perfectly simultaneous. If your migration removed or renamed anything the old code reads, every one of those requests fails.&lt;/p&gt;

&lt;p&gt;Flip the ordering and you trade one problem for another. Run migrations after the symlink flip, and new code briefly runs against the old schema, crashing on columns that don't exist yet. Queue workers make the window wider: a worker mid-job during the deploy finishes that job on old code, against whatever schema exists at that moment.&lt;/p&gt;

&lt;p&gt;Rollback makes the asymmetry worse. Code rollback is instant, the symlink just points back at the previous release. Schema rollback is not. &lt;code&gt;migrate:rollback&lt;/code&gt; in a panic, against live traffic, on a table that just got rebuilt, is how small incidents become large ones. So the practical rule falls out naturally: &lt;strong&gt;every migration must be safe to run while the previous release is still serving traffic, and every release must run correctly against the next release's schema.&lt;/strong&gt; One release of compatibility, in both directions. Expand-and-contract is just the systematic way to satisfy that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Migration Operations Are Actually Dangerous?
&lt;/h2&gt;

&lt;p&gt;Most migrations are fine. Adding a nullable column, adding a table, adding most indexes: old code ignores what it doesn't know about, and nothing breaks. The danger is concentrated in a small set of operations that change or remove something the running release depends on.&lt;/p&gt;

&lt;p&gt;OperationWhy it breaks live trafficExpand-and-contract alternative*&lt;em&gt;Rename a column or table&lt;/em&gt;&lt;em&gt;Old code still selects and writes the old name. Every query touching it fails the moment the migration commits.Add the new column, dual-write from code, backfill, flip reads, drop the old column in a later release.&lt;/em&gt;&lt;em&gt;Drop a column or table&lt;/em&gt;&lt;em&gt;Old code (and cached &lt;code&gt;SELECT *&lt;/code&gt; expectations, serialized queue jobs, in-flight requests) still references it.Stop referencing it in release N. Drop it in release N+1, once nothing running can touch it.&lt;/em&gt;&lt;em&gt;Change a column type&lt;/em&gt;&lt;em&gt;The &lt;code&gt;ALTER&lt;/code&gt; often rebuilds the table (locks, replication lag), and old code may write values invalid under the new type, or read values it can't handle.Add a new column with the new type, dual-write, backfill, flip reads, drop the old column later.&lt;/em&gt;&lt;em&gt;Add &lt;code&gt;NOT NULL&lt;/code&gt; to an existing column&lt;/em&gt;&lt;em&gt;Old code inserts rows without that column and hits a constraint violation instantly. On Postgres, &lt;code&gt;SET NOT NULL&lt;/code&gt; also takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock while it scans the table.Add the column nullable, dual-write, backfill, then enforce &lt;code&gt;NOT NULL&lt;/code&gt; only after every writer populates it (using &lt;code&gt;CHECK ... NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE&lt;/code&gt; on Postgres).&lt;/em&gt;&lt;em&gt;Add a unique index to a big table&lt;/em&gt;*The build can lock writes, and old code may insert duplicates mid-build, failing the migration.Deduplicate first, then build the index without blocking writes: &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; on Postgres, &lt;code&gt;ALGORITHM=INPLACE&lt;/code&gt; on MySQL.&lt;/p&gt;

&lt;p&gt;A note on engine behavior, because "it's fast on my machine" hides real differences. MySQL 8 performs many &lt;code&gt;ALTER TABLE&lt;/code&gt; operations as &lt;code&gt;INSTANT&lt;/code&gt; or &lt;code&gt;INPLACE&lt;/code&gt;: adding a nullable column at the end of a table is metadata-only and effectively free at any table size. But type changes and some &lt;code&gt;NOT NULL&lt;/code&gt; additions still require a full table rebuild (&lt;code&gt;ALGORITHM=COPY&lt;/code&gt;), which on a 50-million-row table means minutes of load and replication lag even when it technically doesn't block reads. Postgres gives you transactional DDL, which is a genuine safety net for failed migrations, but &lt;code&gt;ALTER TABLE&lt;/code&gt; still takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock. A lock that waits behind one long-running query will queue every other query behind it, and a "fast" migration turns into a site-wide stall. Fast DDL is not the same as safe DDL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Expand-and-Contract Pattern?
&lt;/h2&gt;

&lt;p&gt;The pattern splits one breaking change into three non-breaking phases, shipped across separate releases. At no point does any deployed release depend on schema that the adjacent release can't tolerate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Expand
&lt;/h3&gt;

&lt;p&gt;Add the new thing without touching the old thing. New nullable column, new table, new index. Deploy code that &lt;strong&gt;writes to both&lt;/strong&gt; old and new locations but still &lt;strong&gt;reads from the old&lt;/strong&gt; one. This release is compatible with the old schema (the new column is nullable, so old rows are fine) and with the old code (which simply ignores the new column). Nothing can break, in either direction, including a rollback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Migrate
&lt;/h3&gt;

&lt;p&gt;Backfill existing rows from old to new, in chunks, from a queued command, never inside the migration itself. A migration that loops over 10 million rows holds your deploy hostage and, on some setups, times out halfway through with no clean resume point. Once the backfill completes and you've verified parity, flip reads to the new column. Gate the flip behind a config check or a feature flag if you want a kill switch; we covered that mechanism in &lt;a href="https://deploynix.io/blog/deploying-laravel-pennant-feature-flags-rolling-out-changes-safely-on-deploynix" rel="noopener noreferrer"&gt;rolling out changes safely with Laravel Pennant&lt;/a&gt;. Keep dual-writing. That's what makes this phase reversible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Contract
&lt;/h3&gt;

&lt;p&gt;Only when no deployed release reads or writes the old column, remove it. This is its own release, deliberately boring. The drop is safe precisely because releases N and N-1 both ignore the column. If you're tempted to fold the contract migration into the same release that flips reads, resist it: that's the exact shortcut that turns a rollback into an incident.&lt;/p&gt;

&lt;p&gt;Three phases, three releases, minimum. It feels slow the first time. It stops feeling slow the first time a Phase 2 release gets rolled back at 5 p.m. and nothing happens, because the schema was compatible in both directions by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: Splitting &lt;code&gt;users.name&lt;/code&gt; Into &lt;code&gt;first_name&lt;/code&gt; and &lt;code&gt;last_name&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Let's do the exact change that burned the team in the intro, done properly. Goal: replace the single &lt;code&gt;name&lt;/code&gt; column with &lt;code&gt;first_name&lt;/code&gt; and &lt;code&gt;last_name&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release 1: Expand and dual-write
&lt;/h3&gt;

&lt;p&gt;The migration adds the new columns, nullable, so existing rows and old code need no changes:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>laravel</category>
      <category>database</category>
      <category>zerodowntime</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Do You Need Laravel Horizon? Plain Workers vs. Horizon in Production</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sun, 13 Sep 2026 11:03:04 +0000</pubDate>
      <link>https://dev.to/deploynix/do-you-need-laravel-horizon-plain-workers-vs-horizon-in-production-43j1</link>
      <guid>https://dev.to/deploynix/do-you-need-laravel-horizon-plain-workers-vs-horizon-in-production-43j1</guid>
      <description>&lt;p&gt;There are two kinds of Laravel teams, and both are running on autopilot. The first kind runs &lt;code&gt;composer require laravel/horizon&lt;/code&gt; on every new project, before a single job class exists, because that's just what you do. The second kind has been running &lt;code&gt;php artisan queue:work&lt;/code&gt; under Supervisor for years and has never once stopped to ask whether they're missing anything. Both are unexamined defaults. Both camps are sometimes wrong.&lt;/p&gt;

&lt;p&gt;Horizon is not "queues, but better". It's a specific tool that trades a hard Redis-protocol requirement and some real operational overhead for two things: visibility into your queue system, and load-based balancing of your worker pool. Whether that trade pays off depends on your queue driver, your queue topology, and, honestly, whether anyone on your team will ever open the dashboard after week two.&lt;/p&gt;

&lt;p&gt;We've configured queue workers for a lot of Laravel apps, from single-server side projects on the database driver to dedicated worker fleets chewing through webhook storms. The pattern we keep seeing is that the Horizon decision is rarely made on the merits. So in this guide we'll lay out what plain workers already give you (it's more than most people think), what Horizon concretely adds and what each addition costs, and a decision framework that ends with an actual verdict for your situation. Not "it depends". A verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do Plain Queue Workers Already Give You?
&lt;/h2&gt;

&lt;p&gt;Here's the part the Horizon-by-default crowd tends to forget: &lt;code&gt;queue:work&lt;/code&gt; is not the budget option. It's the same worker loop Horizon itself runs under the hood. Almost everything people credit to Horizon, retries, backoff, timeouts, failure handling, is core Laravel queue behavior that you get with zero extra packages.&lt;/p&gt;

&lt;p&gt;A plain worker on any driver gives you automatic retries with &lt;code&gt;--tries&lt;/code&gt;, exponential or fixed backoff via &lt;code&gt;--backoff&lt;/code&gt; or a &lt;code&gt;backoff()&lt;/code&gt; method on the job, per-job timeouts, and a &lt;code&gt;failed_jobs&lt;/code&gt; table that captures the full exception and payload when a job exhausts its attempts. You can retry failed jobs from the CLI with &lt;code&gt;queue:retry&lt;/code&gt;, inspect them with &lt;code&gt;queue:failed&lt;/code&gt;, and prune them on a schedule.&lt;/p&gt;

&lt;p&gt;Priority handling works too, and it's simpler than people expect. Pass a comma-separated queue list and the worker drains them strictly in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:app-worker]&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s_%(process_num)02d&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/app/artisan queue:work redis --queue=payments,default,emails --sleep=3 --tries=3 --backoff=10 --max-time=3600&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploynix&lt;/span&gt;
&lt;span class="py"&gt;numprocs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/app/storage/logs/worker.log&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;120&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stanza gives you four workers that always check &lt;code&gt;payments&lt;/code&gt; first, then &lt;code&gt;default&lt;/code&gt;, then &lt;code&gt;emails&lt;/code&gt;. Want more throughput on one queue? Add a second program block with &lt;code&gt;numprocs=8&lt;/code&gt; pinned to that queue alone. Per-worker tuning of memory limits, sleep intervals, and PHP versions is all right there in the command line. And critically, this works on every queue driver Laravel supports: database, Redis, Valkey, SQS, Beanstalkd. We covered the driver and retry mechanics in depth in our &lt;a href="https://deploynix.io/blog/laravel-queues-deep-dive-connections-workers-and-retry-strategies-on-deploynix" rel="noopener noreferrer"&gt;Laravel queues deep dive&lt;/a&gt;, and none of it requires Horizon.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Plain Workers Don't Give You
&lt;/h3&gt;

&lt;p&gt;So what's actually missing? Four things, and they're all about observation and adaptation rather than execution.&lt;/p&gt;

&lt;p&gt;First, visibility. With plain workers, your window into the queue is &lt;code&gt;redis-cli llen&lt;/code&gt;, database queries, or log grepping. There's no answer to "what's our p95 wait time on the exports queue" without building it yourself. Second, load-based balancing. Your &lt;code&gt;numprocs&lt;/code&gt; allocation is static. If &lt;code&gt;emails&lt;/code&gt; is empty and &lt;code&gt;webhooks&lt;/code&gt; is drowning, those email workers sit idle. Third, tag-based search. Finding every job related to customer #4182 across pending, processing, and failed states is a manual archaeology project. Fourth, one-click retry from a UI, which matters more than it sounds when a non-CLI teammate is on support duty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Runs on any queue driver. Dead simple mental model: Supervisor keeps N processes alive, each process runs jobs. No extra package to update, no dashboard to secure, no extra memory overhead. Static allocation is predictable capacity planning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Single-queue apps, database-driver apps, teams that alert on symptoms (queue depth, oldest-job age) rather than watching dashboards, and anyone who values fewer moving parts over richer tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Zero built-in observability. Worker allocation can't adapt to shifting load between queues. Operational queue questions get answered with ad-hoc CLI spelunking, usually during an incident, which is the worst time to be writing Redis commands from memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Horizon Actually Add?
&lt;/h2&gt;

&lt;p&gt;Horizon is a supervisor for your workers and a dashboard for your queues, in one package. Understanding the first half is the key to understanding its ops model: you don't run one Supervisor process per worker anymore. You run exactly one &lt;code&gt;horizon&lt;/code&gt; process, and Horizon forks and manages its own pool of child workers according to your config. Supervisor watches Horizon; Horizon watches everything else.&lt;/p&gt;

&lt;p&gt;That inversion is what makes the features possible. Because Horizon owns the pool, it can resize it. Let's go feature by feature, with the cost attached to each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dashboard.&lt;/strong&gt; Real-time throughput, per-queue wait times, job runtime distributions, failed job details with full stack traces and payloads. Cost: it's a route in your app that must be auth-gated in production (more on that below), and the metrics have retention limits, so it complements rather than replaces real monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-balancing.&lt;/strong&gt; This is the killer feature, and we'll say that plainly. With &lt;code&gt;balance =&amp;gt; 'auto'&lt;/code&gt;, Horizon shifts worker processes between queues based on current load and wait time. When &lt;code&gt;webhooks&lt;/code&gt; spikes, workers migrate there; when it drains, they flow back. Cost: essentially none beyond running Horizon at all. This feature alone is the reason most teams who should adopt Horizon should adopt it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job tags.&lt;/strong&gt; Jobs are auto-tagged with their Eloquent model IDs, and you can add custom tags. Searching "everything touching order 5512" becomes a text box. Cost: tag data lives in Redis, adding a bit of memory usage on your Redis or Valkey instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click retry and failed job UX.&lt;/strong&gt; A support engineer can find and retry a failed job without SSH access. If you've ever lost an afternoon to &lt;a href="https://deploynix.io/blog/debugging-laravel-queue-failures-in-production-without-losing-messages" rel="noopener noreferrer"&gt;debugging queue failures in production&lt;/a&gt;, you know what this is worth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metrics snapshots.&lt;/strong&gt; Throughput and runtime trends over time, powered by a scheduled &lt;code&gt;horizon:snapshot&lt;/code&gt; command. Cost: you must remember to schedule it, and plenty of teams don't, then wonder why the metrics tab is empty.&lt;/p&gt;

&lt;p&gt;Now the bill for all of it. Horizon requires the &lt;code&gt;redis&lt;/code&gt; queue driver, full stop. It works perfectly with Valkey because Valkey speaks the Redis wire protocol, but database, SQS, and Beanstalkd queues are out. Each Horizon-managed worker carries a bit more memory overhead than a bare &lt;code&gt;queue:work&lt;/code&gt; process, which adds up on small servers running large pools. And you've added a package that must track your Laravel version on every upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Load-adaptive worker allocation, genuine observability, tag search, humane failed-job workflow, and centralized worker config in a PHP file that ships with your code instead of living in server-side ini files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Multi-queue apps on Redis or Valkey with uneven, shifting load, teams where multiple people interact with queue operations, and anyone running enough job volume that "what is the queue doing right now" is a daily question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Redis-protocol stores only. Extra memory per process. A dashboard that's a security hole if left ungated. One more dependency in every framework upgrade. And a subtle one: Horizon becomes a single point of failure for your whole worker pool, so you must monitor the monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plain Workers vs. Horizon: Side by Side
&lt;/h2&gt;

&lt;p&gt;Before the decision framework, here's the full comparison in one place. We've split it into what the software does and what running it demands of you, because the second table is where most blog posts go quiet.&lt;/p&gt;

&lt;p&gt;CapabilityPlain &lt;code&gt;queue:work&lt;/code&gt;HorizonQueue driversDatabase, Redis/Valkey, SQS, BeanstalkdRedis/Valkey onlyRetries, backoff, timeoutsYes (core Laravel)Yes (same core mechanics)Failed job storage&lt;code&gt;failed_jobs&lt;/code&gt; table&lt;code&gt;failed_jobs&lt;/code&gt; table + dashboard UIQueue priorityStatic, via &lt;code&gt;--queue=a,b,c&lt;/code&gt; orderPriority order plus load-based balancingWorker allocationFixed &lt;code&gt;numprocs&lt;/code&gt; per stanzaDynamic (&lt;code&gt;auto&lt;/code&gt;), even (&lt;code&gt;simple&lt;/code&gt;), or fixed (&lt;code&gt;false&lt;/code&gt;)VisibilityCLI + logs, build your ownDashboard: wait times, throughput, runtimesJob search by tagNoYesRetry from UINoYesLong-term metricsNoSnapshots (with scheduled command)&lt;br&gt;
Operational concernPlain &lt;code&gt;queue:work&lt;/code&gt;HorizonSupervisor programs to manageOne per worker groupExactly oneWorker config lives inServer-side ini files&lt;code&gt;config/horizon.php&lt;/code&gt;, versioned with codeDeploy-time restart&lt;code&gt;php artisan queue:restart&lt;/code&gt;&lt;code&gt;php artisan horizon:terminate&lt;/code&gt;Extra scheduled tasksOptionally &lt;code&gt;queue:prune-failed&lt;/code&gt;&lt;code&gt;horizon:snapshot&lt;/code&gt; every 5 min + pruningDashboard security workNone (no dashboard)Required: gate or &lt;code&gt;Horizon::auth&lt;/code&gt;Memory footprintBaselineBaseline + master process + per-child overheadFailure blast radiusOne dead process = one worker downHorizon dies = entire pool downPackage upgrade surfaceNoneTracks Laravel major versions&lt;/p&gt;

&lt;p&gt;Read that second table honestly. Horizon centralizes and simplifies a lot, one process, config in git, but it concentrates risk and adds obligations. Neither column is free.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which Three Questions Decide It?
&lt;/h2&gt;

&lt;p&gt;Skip the twenty-point checklists. In practice, three questions settle this decision for almost every team we've worked with. Answer them in order.&lt;/p&gt;
&lt;h3&gt;
  
  
  Question 1: Are You Already on Redis or Valkey?
&lt;/h3&gt;

&lt;p&gt;This is a gate, not a preference. Horizon requires a Redis-protocol queue store. If you're on the database driver and it's meeting your throughput needs, Horizon would force a driver migration first, and "install a dashboard" is a bad reason to change your queue's storage engine. If you're on SQS because you like managed infrastructure, same answer: Horizon is simply off the table.&lt;/p&gt;

&lt;p&gt;If you're already on Redis, or on Valkey, which is wire-compatible and works with Horizon without any configuration difference, the gate is open and you move to question two. Unsure about the Valkey side of that sentence? We compared them directly in &lt;a href="https://deploynix.io/blog/valkey-vs-redis-for-laravel-caching-and-queues-what-you-need-to-know" rel="noopener noreferrer"&gt;Valkey vs. Redis for Laravel caching and queues&lt;/a&gt;; the short version is that for queue workloads they behave identically.&lt;/p&gt;
&lt;h3&gt;
  
  
  Question 2: Do You Have Multiple Queues With Shifting Load?
&lt;/h3&gt;

&lt;p&gt;One queue with steady volume gains almost nothing from auto-balancing, because there's nothing to balance. But the moment you're running &lt;code&gt;mail&lt;/code&gt;, &lt;code&gt;exports&lt;/code&gt;, &lt;code&gt;webhooks&lt;/code&gt;, and &lt;code&gt;default&lt;/code&gt;, and their relative load changes hour to hour, static &lt;code&gt;numprocs&lt;/code&gt; allocation forces an ugly choice: overprovision every queue for its worst hour, or accept latency when the spike lands on the underweighted one.&lt;/p&gt;

&lt;p&gt;Horizon's &lt;code&gt;auto&lt;/code&gt; strategy dissolves that trade-off by moving workers to wherever the wait time is. In our experience this is the single feature that justifies Horizon on its own. If you answered yes here and yes to question one, you should probably be running Horizon, and the rest is implementation detail.&lt;/p&gt;
&lt;h3&gt;
  
  
  Question 3: Will Anyone Actually Look at the Dashboard?
&lt;/h3&gt;

&lt;p&gt;Be honest. A dashboard nobody opens is negative value: it's attack surface, memory, and upgrade work in exchange for nothing. If your team's operational style is alert-driven, page me when queue depth exceeds X or the oldest job is older than Y, and nobody will browse queue graphs between incidents, then Horizon's observability features will rot quietly.&lt;/p&gt;

&lt;p&gt;But if support engineers need to look up a customer's stuck export, or you review throughput trends when planning capacity, the dashboard earns its keep weekly. Two or three yes answers means install it. One yes, on question two, still usually means install it, because auto-balancing works whether or not anyone watches. Zero yes answers means plain workers, and you should feel good about that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which Setup Fits Your Team Profile?
&lt;/h2&gt;

&lt;p&gt;The three questions generalize, but most teams recognize themselves in one of a few profiles. Here's how we'd call each one.&lt;/p&gt;

&lt;p&gt;ProfileDriverQueuesVerdictSolo dev, small appDatabaseOne &lt;code&gt;default&lt;/code&gt; queuePlain workers, full stopSmall team, modest volumeRedis/Valkey1-2 stable queuesPlain workers, revisit at 3+ queuesSaaS with mixed workloadsRedis/ValkeyMail, exports, webhooks, spikyHorizon, it earns its keepHigh-scale worker fleetRedis/ValkeyMany queues, multiple serversHorizon per worker server&lt;/p&gt;

&lt;p&gt;The solo developer with a single default queue on the database driver should not install Horizon, and we'd go further: doing so is a small mistake. You'd take on a Redis migration, a security obligation, and an upgrade dependency to get a dashboard for a queue you can fully understand with &lt;code&gt;php artisan queue:failed&lt;/code&gt; and a depth check. Spend that effort on your product.&lt;/p&gt;

&lt;p&gt;The SaaS profile is Horizon's home turf. Mail must go out promptly, exports are heavy and bursty, webhooks arrive in storms you don't control. This is exactly the shifting multi-queue load that auto-balancing was built for, and the tag search pays for itself the first time support asks "did customer X's report ever generate?"&lt;/p&gt;

&lt;p&gt;The high-scale fleet, dedicated worker servers processing serious volume, runs one Horizon process per worker server, all pointed at the same Redis or Valkey backend. Each Horizon instance balances its local pool; the dashboard aggregates across all of them. Capacity beyond that is a horizontal scaling story, which we walked through in &lt;a href="https://deploynix.io/blog/processing-1-million-jobs-a-day-scaling-laravel-queues-on-deploynix" rel="noopener noreferrer"&gt;processing 1 million jobs a day&lt;/a&gt;. The one caution at this scale: Horizon's metrics are queue-level, not host-level, so you still need real server monitoring underneath it.&lt;/p&gt;
&lt;h2&gt;
  
  
  How Do You Run Each Well in Production?
&lt;/h2&gt;

&lt;p&gt;Whichever side you land on, the difference between a solid setup and a flaky one comes down to a handful of details. Here they are for both paths.&lt;/p&gt;
&lt;h3&gt;
  
  
  Supervising Horizon Correctly
&lt;/h3&gt;

&lt;p&gt;The most common Horizon mistake we see is treating it like a plain worker in Supervisor. It isn't. You run exactly one process, and because Horizon forks children, the stop settings matter enormously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:horizon]&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/app/artisan horizon&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;deploynix&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;stdout_logfile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/var/www/app/storage/logs/horizon.log&lt;/span&gt;
&lt;span class="py"&gt;stopwaitsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3600&lt;/span&gt;
&lt;span class="py"&gt;stopasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;killasgroup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines carry the weight here. &lt;code&gt;stopwaitsecs&lt;/code&gt; must exceed your longest-running job, or Supervisor will SIGKILL Horizon mid-job during a restart and you'll get mysterious half-completed work. If your biggest export takes 40 minutes, 3600 seconds is right; a default of 10 is a data-corruption machine. &lt;code&gt;stopasgroup&lt;/code&gt; and &lt;code&gt;killasgroup&lt;/code&gt; ensure signals reach the child workers Horizon spawned, not just the master, so you never orphan a pool of zombie workers processing jobs nobody is supervising.&lt;/p&gt;

&lt;p&gt;Then the pool itself lives in &lt;code&gt;config/horizon.php&lt;/code&gt;, versioned with your code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'environments'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'production'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'mail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'webhooks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'minProcesses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'maxProcesses'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'balanceMaxShift'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'balanceCooldown'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'supervisor-exports'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'connection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exports'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'tries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the three balance strategies in action. &lt;code&gt;auto&lt;/code&gt; shifts processes between mail, webhooks, and default based on load. &lt;code&gt;simple&lt;/code&gt; (not shown) would split them evenly and never move them. &lt;code&gt;false&lt;/code&gt; gives the exports queue its own dedicated fixed-size supervisor, which is the right call for long-running jobs whose timeout profile differs wildly from everything else. Mixing strategies across supervisors like this is normal and encouraged; the &lt;a href="https://laravel.com/docs/13.x/horizon" rel="noopener noreferrer"&gt;official Horizon docs&lt;/a&gt; cover the full option set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploys, Dashboard Security, and Snapshots
&lt;/h3&gt;

&lt;p&gt;Deploys must tell workers to finish their current job and restart on the new code. For plain workers that's &lt;code&gt;php artisan queue:restart&lt;/code&gt;; Horizon's parallel is &lt;code&gt;horizon:terminate&lt;/code&gt;, and Supervisor's autorestart brings it back up on the fresh release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# deploy hook, after the new release is linked&lt;/span&gt;
php artisan config:cache
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan horizon:terminate   &lt;span class="c"&gt;# graceful: finishes in-flight jobs, then exits&lt;/span&gt;
&lt;span class="c"&gt;# plain-worker equivalent: php artisan queue:restart&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more obligations that plain workers don't have. First, gate the dashboard. Out of the box Horizon is only accessible in the &lt;code&gt;local&lt;/code&gt; environment; in production you define who gets in via the &lt;code&gt;viewHorizon&lt;/code&gt; gate in &lt;code&gt;HorizonServiceProvider&lt;/code&gt; (or &lt;code&gt;Horizon::auth&lt;/code&gt;). Ship it ungated and you've published your job payloads, which routinely contain emails, IDs, and tokens, to the internet. Second, schedule &lt;code&gt;horizon:snapshot&lt;/code&gt; every five minutes, or your metrics tab stays empty forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'horizon:snapshot'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;everyFiveMinutes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Who Watches the Watcher?
&lt;/h3&gt;

&lt;p&gt;Here's the failure mode nobody plans for: Horizon itself dies. Because it supervises your entire pool, its death takes every worker with it, and the dashboard that would have told you can't load because the process behind it is gone. Supervisor's autorestart handles crashes, but not a wedged process, a full disk, or an OOM-killed master on a memory-starved box.&lt;/p&gt;

&lt;p&gt;So monitor the monitor. Alert on queue depth and oldest-job age from outside the Horizon process, and watch host-level CPU and memory on your worker servers so you catch the OOM spiral before it kills the pool. We've found that queue-level metrics and &lt;a href="https://deploynix.io/blog/real-time-server-monitoring-cpu-memory-disk-alerts-before-your-users-notice" rel="noopener noreferrer"&gt;host-level server monitoring&lt;/a&gt; fail at different times, which is exactly why you want both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Both Paths on Deploynix
&lt;/h2&gt;

&lt;p&gt;Since we build deployment infrastructure for Laravel, here's concretely how the two paths look on our platform. This maps one-to-one onto everything above, so it doubles as a worked example even if you're provisioning by hand.&lt;/p&gt;

&lt;p&gt;The plain-worker path is the queue workers UI. You define a worker per app: connection, queue list, process count, timeout, tries, sleep, and PHP version, and Deploynix generates and installs the corresponding Supervisor config on the server. It's the exact stanza pattern from earlier in this post, just without hand-editing ini files over SSH. Change the process count in the UI, and Supervisor reloads with the new allocation.&lt;/p&gt;

&lt;p&gt;The Horizon path deliberately does not use that feature. Because Horizon supervises its own pool, you run it as a daemon instead: one managed long-running &lt;code&gt;php artisan horizon&lt;/code&gt; process with the group-signal and stop-timeout semantics handled for you. Your pool sizing then lives where it should, in &lt;code&gt;config/horizon.php&lt;/code&gt; in your repository. The driver requirement is already met, since Deploynix app servers ship with Valkey, which Horizon treats identically to Redis. For dedicated fleets, the worker server type gives you hosts whose only job is running Horizon against your queue backend, and zero-downtime deploys can run &lt;code&gt;horizon:terminate&lt;/code&gt; in a deploy hook so releases never kill in-flight jobs.&lt;/p&gt;

&lt;p&gt;One thing we'd flag from experience: teams sometimes assume Horizon's dashboard means they can skip server monitoring on worker boxes. It can't. Horizon sees queues; it doesn't see the memory pressure that's about to OOM-kill it. Our server monitoring watches CPU, memory, and load on the host, which is precisely the layer that catches Horizon's own failure modes. If you go the Horizon route, we've written up the full production setup in &lt;a href="https://deploynix.io/blog/running-laravel-horizon-on-deploynix-for-queue-monitoring-at-scale" rel="noopener noreferrer"&gt;running Laravel Horizon on Deploynix&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Hard Is It to Migrate Between the Two?
&lt;/h2&gt;

&lt;p&gt;Good news: this decision is cheap to reverse, in both directions. Your job classes, retry logic, backoff methods, and &lt;code&gt;failed_jobs&lt;/code&gt; table are identical under both systems, because Horizon reuses Laravel's core queue mechanics rather than replacing them. Migration is an infrastructure swap, not a rewrite.&lt;/p&gt;

&lt;p&gt;Going from plain workers to Horizon: get on Redis or Valkey if you aren't already (the only potentially non-trivial step), install the package, translate each Supervisor stanza into a Horizon supervisor block with matching queue lists and process counts, then replace your N worker programs with the single Horizon program. Swap &lt;code&gt;queue:restart&lt;/code&gt; for &lt;code&gt;horizon:terminate&lt;/code&gt; in your deploy script, gate the dashboard, schedule snapshots. Start with &lt;code&gt;balance =&amp;gt; 'simple'&lt;/code&gt; to reproduce your old static allocation exactly, confirm throughput matches, then switch to &lt;code&gt;auto&lt;/code&gt; and watch the wait times drop.&lt;/p&gt;

&lt;p&gt;Going the other way is even simpler, and yes, teams do it, usually after realizing nobody opened the dashboard in six months. Read your process counts out of &lt;code&gt;config/horizon.php&lt;/code&gt;, write the equivalent &lt;code&gt;queue:work&lt;/code&gt; stanzas, stop Horizon, start the workers, swap the deploy command back. You lose tags, balancing, and the UI; you keep every job, every retry policy, and every failed-job record. Either migration is an afternoon, not a quarter. That's worth internalizing, because it means you should decide for the team you are now, not the team you might be in three years.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Horizon work with Valkey?
&lt;/h3&gt;

&lt;p&gt;Yes, without any special configuration. Valkey speaks the Redis wire protocol, so Laravel's Redis queue driver, and therefore Horizon, can't tell the difference. Point your &lt;code&gt;redis&lt;/code&gt; connection at a Valkey instance and Horizon's balancing, tags, and metrics all work exactly as they do on Redis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Horizon with the database or SQS queue driver?
&lt;/h3&gt;

&lt;p&gt;No. Horizon requires the Redis queue driver; that's a hard dependency, not a soft recommendation. If you're on database or SQS and happy, stay on plain &lt;code&gt;queue:work&lt;/code&gt; workers, which support every driver. Adopting Horizon means migrating your queue store first, and that migration should be justified on its own merits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Horizon faster than plain queue:work?
&lt;/h3&gt;

&lt;p&gt;Not per job. Individual jobs execute through the same worker mechanics either way, and Horizon's children actually carry slightly more memory overhead. Where Horizon improves real-world latency is allocation: auto-balancing moves workers to backed-up queues, so jobs wait less even though they don't run faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need Supervisor if I use Horizon?
&lt;/h3&gt;

&lt;p&gt;Yes, just less of it. Something must keep the &lt;code&gt;horizon&lt;/code&gt; master process alive across crashes and reboots, and that's Supervisor (or systemd, or a platform-managed daemon). The difference is you supervise one process instead of one per worker, and Horizon manages the pool beneath it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to my failed jobs if I switch to or from Horizon?
&lt;/h3&gt;

&lt;p&gt;Nothing. Both setups write failures to the same &lt;code&gt;failed_jobs&lt;/code&gt; table with the same payloads and exceptions, and &lt;code&gt;queue:retry&lt;/code&gt; works identically in both worlds. Horizon adds a UI over that table; removing Horizon removes the UI, not the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;Here's the framework compressed to its core. On the database driver or SQS with no pain? Plain workers, and don't let anyone make you feel behind for it. On Redis or Valkey with multiple queues and shifting load? Horizon, primarily for auto-balancing, with the dashboard as a bonus. Somewhere in between? Default to plain workers and let a real problem, not a habit, pull you toward Horizon. The migration is cheap, so decide for today.&lt;/p&gt;

&lt;p&gt;Whichever way you land, the production details decide whether it works: correct stop timeouts, graceful restarts on deploy, a gated dashboard, and host-level monitoring underneath it all. Your next step is a fifteen-minute audit of your current setup against the stanzas in this post, starting with &lt;code&gt;stopwaitsecs&lt;/code&gt; versus your longest job. In our experience, that one line is where most queue setups are quietly broken.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>horizon</category>
      <category>queues</category>
      <category>supervisor</category>
    </item>
    <item>
      <title>Migrating a Laravel App From MySQL to PostgreSQL: A Practical Guide</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:03:04 +0000</pubDate>
      <link>https://dev.to/deploynix/migrating-a-laravel-app-from-mysql-to-postgresql-a-practical-guide-3pg8</link>
      <guid>https://dev.to/deploynix/migrating-a-laravel-app-from-mysql-to-postgresql-a-practical-guide-3pg8</guid>
      <description>&lt;p&gt;Let's start with the honest part: most Laravel apps do not need to switch databases. MySQL powers an enormous share of production Laravel deployments, it's fast, it's well understood, and Eloquent abstracts away most of the differences anyway. If your app is healthy on MySQL and your team knows it well, the highest-value move is usually to stay put and invest in better indexes and query hygiene instead.&lt;/p&gt;

&lt;p&gt;But some apps do outgrow MySQL in specific, identifiable ways. Heavy JSON workloads that want real indexing. Migration files that can't be rolled back atomically because MySQL commits DDL implicitly. Deadlocks from gap locking that nobody can quite explain. Reporting queries that would be two lines with a partial index or a proper &lt;code&gt;string_agg&lt;/code&gt;. If you've hit two or three of those, PostgreSQL starts earning its migration cost.&lt;/p&gt;

&lt;p&gt;This post is a runbook, not advocacy. We've helped teams move Laravel apps between engines on Deploynix, and the pattern is consistent: the data transfer is the easy part, and the dialect differences hiding in your codebase are the hard part. We'll cover how to decide, how to audit, how to convert the schema with pgloader, how to fix the Laravel layer, and how to cut over with a rollback path you'd actually trust at 2 a.m.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- Switch for concrete wins (transactional DDL, JSONB, partial indexes), not because Postgres is fashionable - Run your test suite against real PostgreSQL in CI weeks before any data moves - pgloader handles ~90% of schema conversion; enums, fulltext, and spatial need hand-work - Verify with row counts, checksums, and sequence resets before flipping &lt;code&gt;.env&lt;/code&gt; - Keep MySQL warm as a rollback target for at least a week&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Should You Switch at All?
&lt;/h2&gt;

&lt;p&gt;This decision deserves more scrutiny than it usually gets. A database migration touches every query your app runs, so the reasons need to be specific to your workload, not aspirational. Here are the reasons that actually justify the work, in our experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactional DDL.&lt;/strong&gt; PostgreSQL wraps schema changes in transactions. If a Laravel migration fails halfway through on MySQL, you're left with a partially applied schema and a &lt;code&gt;migrations&lt;/code&gt; table that disagrees with reality. On Postgres, the whole migration rolls back cleanly. For teams that deploy schema changes frequently, this alone removes a whole category of production incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSONB with GIN indexes.&lt;/strong&gt; MySQL's &lt;code&gt;JSON&lt;/code&gt; type works, but querying inside it at scale is painful. Postgres &lt;code&gt;jsonb&lt;/code&gt; stores a parsed binary representation and supports GIN indexes, so &lt;code&gt;WHERE payload @&amp;gt; '{"status": "failed"}'&lt;/code&gt; can use an index instead of scanning the table. If you have JSON columns in &lt;code&gt;WHERE&lt;/code&gt; clauses today, this is probably your biggest single win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial and expression indexes.&lt;/strong&gt; &lt;code&gt;CREATE INDEX ... WHERE deleted_at IS NULL&lt;/code&gt; indexes only the rows you query. An index on &lt;code&gt;lower(email)&lt;/code&gt; gives you case-insensitive uniqueness without hacks. MySQL has functional indexes now, but Postgres's implementation is more mature and more flexible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Richer types and stricter conformance.&lt;/strong&gt; Native arrays, ranges, &lt;code&gt;inet&lt;/code&gt;, real booleans, and a query planner that follows the SQL standard closely. Postgres rejects garbage like zero-dates instead of storing it, which surfaces bugs earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No gap-lock surprises.&lt;/strong&gt; InnoDB's default REPEATABLE READ isolation uses gap locks that cause deadlocks in insert-heavy workloads under conditions that are genuinely hard to reason about. Postgres's MVCC model under READ COMMITTED behaves more predictably for typical web traffic.&lt;/p&gt;

&lt;p&gt;Now the reasons &lt;em&gt;not&lt;/em&gt; to switch, which are just as real. Your team knows MySQL: its &lt;code&gt;EXPLAIN&lt;/code&gt; output, its tuning knobs, its failure modes. That operational knowledge has value, and you're throwing it away. Your ecosystem tooling (backup scripts, monitoring dashboards, that one replication setup someone built in 2023) all assumes MySQL. And "Postgres is trendy" is not a reason. Neither is a conference talk. If you can't name the specific feature that fixes a specific problem you have today, don't migrate. If you're still weighing engines for a &lt;em&gt;new&lt;/em&gt; project, our comparison of &lt;a href="https://deploynix.io/blog/mysql-vs-mariadb-vs-postgresql-on-deploynix-which-should-you-pick" rel="noopener noreferrer"&gt;MySQL vs MariaDB vs PostgreSQL on Deploynix&lt;/a&gt; is the better starting point, because greenfield choice and production migration are very different decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should You Audit Before Touching Anything?
&lt;/h2&gt;

&lt;p&gt;The pre-flight audit is where migrations are won or lost. Eloquent-generated queries mostly translate cleanly between engines. Raw SQL does not, and every Laravel codebase over a couple of years old has more raw SQL than its authors remember.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grep for raw SQL
&lt;/h3&gt;

&lt;p&gt;Start by building an inventory of every place your code bypasses the query builder's dialect abstraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Every raw SQL escape hatch in the codebase&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEn&lt;/span&gt; &lt;span class="s2"&gt;"DB::raw|whereRaw|orderByRaw|selectRaw|havingRaw|groupByRaw|DB::statement|DB::select&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|DB::unprepared"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  app/ database/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; raw-sql-inventory.txt

&lt;span class="c"&gt;# Known MySQL-isms that will break or silently misbehave on Postgres&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEin&lt;/span&gt; &lt;span class="s2"&gt;"FIELD&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|GROUP_CONCAT|IFNULL&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|DATE_FORMAT&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|ON DUPLICATE KEY|STRAIGHT_JOIN|SQL_CALC_FOUND_ROWS|RAND&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s2"&gt;|UNIX_TIMESTAMP"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  app/ database/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; raw-sql-inventory.txt

&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; raw-sql-inventory.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line in that file needs a decision: rewrite portably, rewrite for Postgres, or delete. Don't guess at the count. We've seen a "we barely use raw SQL" app produce 140 hits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inventory the behavior differences
&lt;/h3&gt;

&lt;p&gt;Beyond raw SQL, the engines simply behave differently in ways that Eloquent cannot paper over. This table is the checklist we work from:&lt;/p&gt;

&lt;p&gt;Behavior&lt;/p&gt;

&lt;p&gt;MySQL&lt;/p&gt;

&lt;p&gt;PostgreSQL&lt;/p&gt;

&lt;p&gt;What breaks&lt;/p&gt;

&lt;p&gt;String comparison&lt;/p&gt;

&lt;p&gt;Case-insensitive by default (&lt;code&gt;utf8mb4_*_ci&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Case-sensitive&lt;/p&gt;

&lt;p&gt;Logins, search, unique emails&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LIKE&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Case-insensitive&lt;/p&gt;

&lt;p&gt;Case-sensitive (use &lt;code&gt;ILIKE&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Every search box&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GROUP BY&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Lenient unless &lt;code&gt;only_full_group_by&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Strict, always&lt;/p&gt;

&lt;p&gt;Reporting queries error out&lt;/p&gt;

&lt;p&gt;Zero dates (&lt;code&gt;0000-00-00&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Accepted in lax modes&lt;/p&gt;

&lt;p&gt;Rejected&lt;/p&gt;

&lt;p&gt;Import fails on dirty data&lt;/p&gt;

&lt;p&gt;Booleans&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tinyint(1)&lt;/code&gt; with 0/1&lt;/p&gt;

&lt;p&gt;Native &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Raw &lt;code&gt;WHERE flag = 1&lt;/code&gt; clauses&lt;/p&gt;

&lt;p&gt;Auto-increment&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AUTO_INCREMENT&lt;/code&gt; counter&lt;/p&gt;

&lt;p&gt;Sequences&lt;/p&gt;

&lt;p&gt;Duplicate-key errors if not reset&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ENUM&lt;/code&gt; columns&lt;/p&gt;

&lt;p&gt;Native column type&lt;/p&gt;

&lt;p&gt;Check constraint or custom type&lt;/p&gt;

&lt;p&gt;pgloader output needs review&lt;/p&gt;

&lt;p&gt;Fulltext search&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FULLTEXT&lt;/code&gt; index, &lt;code&gt;MATCH ... AGAINST&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsvector&lt;/code&gt; + GIN, &lt;code&gt;@@&lt;/code&gt; operator&lt;/p&gt;

&lt;p&gt;Search features need rewriting&lt;/p&gt;

&lt;p&gt;Upserts&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ON CONFLICT ... DO UPDATE&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Raw upsert statements&lt;/p&gt;

&lt;p&gt;Ordering by list&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FIELD(col, ...)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CASE WHEN&lt;/code&gt; or &lt;code&gt;array_position&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Custom sort orders&lt;/p&gt;

&lt;p&gt;String aggregation&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GROUP_CONCAT&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;string_agg&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Report exports&lt;/p&gt;

&lt;p&gt;DDL in transactions&lt;/p&gt;

&lt;p&gt;Implicit commit&lt;/p&gt;

&lt;p&gt;Fully transactional&lt;/p&gt;

&lt;p&gt;Actually a Postgres win&lt;/p&gt;

&lt;p&gt;Identifier quoting&lt;/p&gt;

&lt;p&gt;Backticks&lt;/p&gt;

&lt;p&gt;Double quotes&lt;/p&gt;

&lt;p&gt;Raw queries with backticks&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;upsert()&lt;/code&gt; in Laravel's query builder abstracts the upsert syntax correctly on both engines. The danger is exclusively in raw statements someone wrote by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check package compatibility and JSON usage
&lt;/h3&gt;

&lt;p&gt;Go through &lt;code&gt;composer.json&lt;/code&gt; and confirm every package that touches the database supports Postgres. Most first-party and popular packages do, but audit anything that ships its own migrations or raw queries: search packages, analytics packages, anything doing spatial work. While you're in there, list every &lt;code&gt;json&lt;/code&gt; column and how it's queried. Columns that only store-and-retrieve are a non-event. Columns queried with &lt;code&gt;whereJsonContains&lt;/code&gt; or &lt;code&gt;-&amp;gt;&amp;gt;'&lt;/code&gt; extraction are your JSONB-plus-GIN upgrade candidates, and worth flagging now so you add the indexes after import.&lt;/p&gt;

&lt;p&gt;Finally, confirm your backups are current and tested before you change anything. If your backup story is "we think cron is running it," fix that first; our guide to &lt;a href="https://deploynix.io/blog/automated-database-backups-set-it-forget-it-sleep-well" rel="noopener noreferrer"&gt;automated database backups&lt;/a&gt; covers the setup for both engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting the Schema With pgloader
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pgloader.io" rel="noopener noreferrer"&gt;pgloader&lt;/a&gt; is the workhorse for MySQL-to-Postgres moves, and it's genuinely good. It reads the MySQL schema, creates equivalent Postgres tables, streams the data across in parallel, converts types on the fly, rebuilds indexes, and resets sequences. For a straightforward schema it can be a one-command migration.&lt;/p&gt;

&lt;p&gt;A production run deserves a real config file rather than command-line flags, because you'll run it several times (staging dry-runs, then the final sync) and you want it reproducible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOAD DATABASE
     FROM mysql://app:secret@10.0.0.11:3306/app_production
     INTO postgresql://app:secret@10.0.0.12:5432/app_production

 WITH include drop, create tables, create indexes,
      reset sequences, foreign keys,
      workers = 8, concurrency = 1,
      rows per range = 50000

 SET PostgreSQL PARAMETERS
      maintenance_work_mem TO '512MB',
      work_mem TO '64MB'

 SET MySQL PARAMETERS
      net_read_timeout  = '600',
      net_write_timeout = '600'

 CAST type json to jsonb drop typemod,
      type tinyint when (= precision 1) to boolean
           drop typemod using tinyint-to-boolean,
      type datetime to timestamptz drop typemod

 ALTER SCHEMA 'app_production' RENAME TO 'public'
;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;CAST&lt;/code&gt; rules are where you encode the decisions from your audit. &lt;code&gt;json&lt;/code&gt; becomes &lt;code&gt;jsonb&lt;/code&gt; (take the win). &lt;code&gt;tinyint(1)&lt;/code&gt; becomes real &lt;code&gt;boolean&lt;/code&gt;, which is correct but is exactly why you audited raw &lt;code&gt;= 1&lt;/code&gt; comparisons earlier. Whether &lt;code&gt;datetime&lt;/code&gt; becomes &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt; depends on your app; if everything runs in UTC (as Laravel defaults encourage), &lt;code&gt;timestamptz&lt;/code&gt; is the safer long-term choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  What pgloader handles well, and what it mangles
&lt;/h3&gt;

&lt;p&gt;It handles tables, data, most types, standard indexes, foreign keys, and sequence resets reliably. Three areas need hand-review every time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enums.&lt;/strong&gt; MySQL &lt;code&gt;ENUM&lt;/code&gt; columns become custom Postgres enum types by default, which are awkward to alter later. Most Laravel teams are better off casting them to &lt;code&gt;varchar&lt;/code&gt; with a check constraint, or just &lt;code&gt;varchar&lt;/code&gt; if validation lives in a PHP enum anyway. Add an explicit &lt;code&gt;CAST&lt;/code&gt; rule so the choice is deliberate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fulltext indexes.&lt;/strong&gt; &lt;code&gt;FULLTEXT&lt;/code&gt; indexes don't translate. pgloader will skip them, and your &lt;code&gt;MATCH ... AGAINST&lt;/code&gt; queries were going to break regardless. Plan a &lt;code&gt;tsvector&lt;/code&gt; column with a GIN index as follow-up work, or move that feature to Meilisearch via Scout and sidestep the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spatial columns.&lt;/strong&gt; MySQL spatial types need PostGIS on the Postgres side and manual mapping. If you have them, budget real time here.&lt;/p&gt;

&lt;p&gt;Run pgloader against staging first, read its summary table line by line, and treat every warning as a defect to investigate. The dry-run is not optional. It's where you discover the zero-dates in a 2019-era table that Postgres will refuse, and you fix the source data before cutover night instead of during it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Laravel Layer
&lt;/h2&gt;

&lt;p&gt;With a converted staging schema in hand, the work moves into the application, and it should start weeks before any production data moves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Point CI at real Postgres first
&lt;/h3&gt;

&lt;p&gt;Most Laravel test suites run on SQLite in memory. That's fast, and it's also exactly why your tests will pass while your app is broken: SQLite hides the dialect differences between MySQL and Postgres. The single highest-leverage step in this whole migration is switching CI to run the suite against a real PostgreSQL service and fixing failures one by one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/tests.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testing&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every red test is a dialect bug found in CI instead of in production. Strict &lt;code&gt;GROUP BY&lt;/code&gt; errors, case-sensitivity assumptions, boolean comparisons: they all surface here, on your schedule. Budget two to four weeks of this running in parallel with normal feature work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The config change
&lt;/h3&gt;

&lt;p&gt;The Laravel side of the connection is refreshingly boring. Add (or update) the &lt;code&gt;pgsql&lt;/code&gt; connection in &lt;code&gt;config/database.php&lt;/code&gt; and drive it from &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/database.php&lt;/span&gt;
&lt;span class="s1"&gt;'pgsql'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pgsql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5432'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'app_production'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'app'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'search_path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'sslmode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'require'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;

&lt;span class="c1"&gt;// .env, at cutover time&lt;/span&gt;
&lt;span class="c1"&gt;// DB_CONNECTION=pgsql&lt;/span&gt;
&lt;span class="c1"&gt;// DB_HOST=10.0.0.12&lt;/span&gt;
&lt;span class="c1"&gt;// DB_PORT=5432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure the &lt;code&gt;pdo_pgsql&lt;/code&gt; PHP extension is installed on every app server before cutover night, not during it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the queries CI surfaces
&lt;/h3&gt;

&lt;p&gt;The fixes follow a handful of patterns. Prefer the portable version where one exists, so you're not just trading one dialect lock-in for another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FIELD() ordering: MySQL only. Rewrite with CASE WHEN (portable).&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"FIELD(severity, 'critical', 'warning', 'ok')"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CASE severity WHEN 'critical' THEN 0
              WHEN 'warning' THEN 1 ELSE 2 END"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;// after&lt;/span&gt;

&lt;span class="c1"&gt;// Case-insensitive search: MySQL LIKE ignores case, Postgres doesn't.&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                 &lt;span class="c1"&gt;// silently case-sensitive now&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email ILIKE ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                            &lt;span class="c1"&gt;// Postgres-native&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lower(email) like ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;     &lt;span class="c1"&gt;// portable&lt;/span&gt;

&lt;span class="c1"&gt;// Booleans in raw SQL: tinyint(1) took 0/1, Postgres boolean won't.&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_active = 1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                           &lt;span class="c1"&gt;// fails on Postgres&lt;/span&gt;
&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                            &lt;span class="c1"&gt;// builder handles both engines&lt;/span&gt;

&lt;span class="c1"&gt;// String aggregation&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"GROUP_CONCAT(name SEPARATOR ', ')"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;// MySQL&lt;/span&gt;
&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"string_agg(name, ', ')"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                     &lt;span class="c1"&gt;// Postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more to check by hand. First, unique email columns: if your app relied on MySQL's case-insensitive collation to prevent &lt;code&gt;Sam@&lt;/code&gt; and &lt;code&gt;sam@&lt;/code&gt; registering twice, add a unique index on &lt;code&gt;lower(email)&lt;/code&gt; (or use &lt;code&gt;citext&lt;/code&gt;) after import, or that protection is gone. Second, any raw &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; statements need rewriting as &lt;code&gt;ON CONFLICT&lt;/code&gt;; queries using Laravel's &lt;code&gt;upsert()&lt;/code&gt; are already fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Cutover Strategy Fits Your App?
&lt;/h2&gt;

&lt;p&gt;There are two sane ways to move the data, and the right one depends on how much downtime you can buy and how big the database is.&lt;/p&gt;

&lt;p&gt;Factor&lt;/p&gt;

&lt;p&gt;Maintenance window (big-bang)&lt;/p&gt;

&lt;p&gt;Dual-run (replicate, then flip)&lt;/p&gt;

&lt;p&gt;Downtime&lt;/p&gt;

&lt;p&gt;Minutes to a few hours&lt;/p&gt;

&lt;p&gt;Near zero&lt;/p&gt;

&lt;p&gt;Complexity&lt;/p&gt;

&lt;p&gt;Low: one pgloader run&lt;/p&gt;

&lt;p&gt;High: CDC tooling, drift monitoring&lt;/p&gt;

&lt;p&gt;Rollback&lt;/p&gt;

&lt;p&gt;Trivial: point &lt;code&gt;.env&lt;/code&gt; back at MySQL&lt;/p&gt;

&lt;p&gt;Trivial early, harder once writes diverge&lt;/p&gt;

&lt;p&gt;Data volume sweet spot&lt;/p&gt;

&lt;p&gt;Up to ~50-100 GB&lt;/p&gt;

&lt;p&gt;Hundreds of GB and up&lt;/p&gt;

&lt;p&gt;Verification&lt;/p&gt;

&lt;p&gt;Once, during the window&lt;/p&gt;

&lt;p&gt;Continuous, before the flip&lt;/p&gt;

&lt;p&gt;Team effort&lt;/p&gt;

&lt;p&gt;One rehearsed evening&lt;/p&gt;

&lt;p&gt;Days to weeks of parallel operation&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The maintenance window approach.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Simple, rehearsable, and verifiable in one sitting. One tool, one data pass, one moment where the truth moves. The rollback story is as clean as it gets because MySQL never stops being complete and consistent; it's just frozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; The majority of Laravel apps. If pgloader moves your staging copy in under an hour, you can buy a window (we schedule 3x the rehearsed duration) and your users tolerate a short read-only period, do this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; The window must be rehearsed on staging with a production-sized dataset, and you need a hard abort time agreed in advance. If the rehearsal takes four hours, this strategy is telling you something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dual-run approach.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Near-zero downtime. You replicate changes from MySQL to Postgres continuously (pgloader for the initial load, then CDC tooling or an application-level double-write for the delta), verify at leisure, and flip when the two sides have matched for days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Large databases where a full pgloader pass takes many hours, or apps with genuine 24/7 write traffic and no tolerable window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Substantially more moving parts, and every moving part is a place for silent drift. Double-writing from the app is deceptively hard to get right under failure conditions. Don't choose this for a 20 GB database because zero downtime sounds nicer; you're trading a one-hour window for weeks of operational risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  The runbook for the window
&lt;/h3&gt;

&lt;p&gt;Rehearse this end to end on staging, with timings written down, before you book the window:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Announce the window; put the app in maintenance mode or flip it read-only. Freeze all writes, including queue workers and scheduled jobs. Verify the freeze by watching MySQL's write counters go flat.&lt;/li&gt;
&lt;li&gt;Run the final pgloader pass against the frozen source.&lt;/li&gt;
&lt;li&gt;Verify. Row counts, checksums, sequences (queries below). This is the go/no-go gate.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;.env&lt;/code&gt; to the &lt;code&gt;pgsql&lt;/code&gt; connection and deploy the config change to all app servers.&lt;/li&gt;
&lt;li&gt;Smoke test the critical paths with real requests: login, checkout or the equivalent, one queued job, one scheduled command.&lt;/li&gt;
&lt;li&gt;Reopen traffic. Watch error rates and slow-query logs like a hawk for the first hour.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 deserves its own code block, because "it looks right" is not verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Row counts: run per table on BOTH sides and diff the results.&lt;/span&gt;
&lt;span class="c1"&gt;--    (information_schema.TABLES row counts are estimates; use COUNT(*).)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Checksums on critical tables: order-independent XOR of row hashes.&lt;/span&gt;
&lt;span class="c1"&gt;-- MySQL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;BIT_XOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CRC32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- PostgreSQL (compare that both sides produce stable, matching values&lt;/span&gt;
&lt;span class="c1"&gt;-- for a seeded staging dataset before trusting this in production)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;BIT_XOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concat_ws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Sequences: pgloader's "reset sequences" should handle this. Verify anyway,&lt;/span&gt;
&lt;span class="c1"&gt;--    because an unreset sequence means duplicate-key errors on the first INSERT.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;setval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pg_get_serial_sequence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Fresh planner statistics before any traffic hits the database.&lt;/span&gt;
&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write down your rollback criteria &lt;em&gt;before&lt;/em&gt; the window: which failures trigger an abort, and who makes the call. And read our post on &lt;a href="https://deploynix.io/blog/recovering-from-a-failed-database-migration-in-production" rel="noopener noreferrer"&gt;recovering from a failed database migration in production&lt;/a&gt; before you need it, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Migration on Deploynix
&lt;/h2&gt;

&lt;p&gt;Here's how this looks in practice on our own platform, since the database server type supports MySQL, MariaDB, and PostgreSQL and the pieces line up naturally with the runbook above.&lt;/p&gt;

&lt;p&gt;Provision a new PostgreSQL database server alongside your existing MySQL one. Nothing about the MySQL server changes; both run in parallel for the whole migration. Because app servers can connect to any database server, staging can point at the new Postgres box weeks early for the CI-and-fixes phase while production traffic continues on MySQL untouched.&lt;/p&gt;

&lt;p&gt;Lock the new server down before any data lands on it. Use firewall rules to restrict the Postgres port to your app servers' IPs only, exactly as your MySQL server should already be configured. A half-migrated database with production data on it is still production data.&lt;/p&gt;

&lt;p&gt;Enable automated backups to S3-compatible storage on the Postgres server from day one, so your first pgloader import is followed by your first restorable backup, and keep the MySQL backup schedule running unchanged. During the transition you're effectively running the &lt;a href="https://deploynix.io/blog/the-3-2-1-backup-rule-for-laravel-apps-on-deploynix" rel="noopener noreferrer"&gt;3-2-1 backup rule&lt;/a&gt; across two engines, which is mildly annoying and entirely worth it.&lt;/p&gt;

&lt;p&gt;The cutover itself is a config change, which means zero-downtime deploys handle the flip: update the environment to point at the Postgres host, deploy, and every app server picks up the new connection on release. If smoke tests fail, deploy the previous environment and you're back on MySQL in one release cycle. Keep the MySQL server provisioned, firewalled, and backed up for at least a week after cutover. It's your rollback target, and a week of monitoring data is what earns the decision to decommission it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens After the Cutover?
&lt;/h2&gt;

&lt;p&gt;The migration isn't done when the site comes back up. The first week on Postgres is an active monitoring period, not a victory lap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statistics first.&lt;/strong&gt; If you skipped it during the window, run &lt;code&gt;VACUUM ANALYZE&lt;/code&gt; immediately. Postgres's planner is flying blind on a freshly imported database until statistics exist, and "Postgres is slower than MySQL was" complaints in week one are very often just missing stats or a missing index, not the engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index review.&lt;/strong&gt; Diff the index list on both sides table by table. Fulltext indexes didn't translate, prefix indexes (&lt;code&gt;INDEX (col(10))&lt;/code&gt;) didn't translate, and anything pgloader warned about needs rebuilding by hand. Then go further: this is the moment to add what MySQL couldn't give you, like partial indexes on soft-deleted tables and GIN indexes on the JSONB columns you flagged in the audit. The principles from our &lt;a href="https://deploynix.io/blog/mysql-indexing-strategies-every-laravel-developer-should-know" rel="noopener noreferrer"&gt;MySQL indexing guide&lt;/a&gt; carry over almost entirely; the syntax and the extra options are what changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch query performance daily.&lt;/strong&gt; Enable &lt;code&gt;pg_stat_statements&lt;/code&gt;, sort by total time each morning, and compare against your mental model of what was slow on MySQL. The planners are different, and a query MySQL handled fine can pick a bad plan on Postgres (and vice versa, pleasantly often). The workflow we describe in &lt;a href="https://deploynix.io/blog/finding-and-fixing-slow-queries-in-laravel-before-they-hit-production" rel="noopener noreferrer"&gt;finding and fixing slow queries in Laravel&lt;/a&gt; applies directly; only the &lt;code&gt;EXPLAIN&lt;/code&gt; output format changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep MySQL warm.&lt;/strong&gt; Frozen, firewalled, backed up, and one config deploy away. After a full week of clean error rates, stable p95 latencies, and successful Postgres backups you've actually test-restored, decommission it and reclaim the spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Abort?
&lt;/h2&gt;

&lt;p&gt;Every good runbook has abort criteria, and deciding them under pressure is how bad nights get worse. Ours, roughly in order of when they'd trigger:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the window:&lt;/strong&gt; if the staging rehearsal can't produce matching row counts and checksums twice in a row, you don't have a migration, you have a science experiment. Fix it in staging or don't book the window. Likewise if the raw-SQL fix list is still growing the week before cutover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;During the window:&lt;/strong&gt; any checksum mismatch on a critical table is an automatic abort. Don't debug data integrity at 1 a.m. against a ticking clock; unfreeze MySQL, reopen, and investigate in daylight. Same if you blow through the hard time limit you set in advance. The whole point of the big-bang strategy is that aborting costs almost nothing: MySQL was never modified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the window:&lt;/strong&gt; the calculus shifts the moment real writes land in Postgres, because rolling back now means losing or hand-porting that data. This is why the smoke-test step exists between the flip and reopening traffic. If errors surface in the first minutes, roll back and lose nothing. If a subtle bug surfaces on day three, you'll almost always fix forward on Postgres rather than roll back, and that's precisely why the week of monitoring with MySQL warm matters: it converts "we think it works" into evidence before the rollback option quietly expires.&lt;/p&gt;

&lt;p&gt;An aborted migration that reruns cleanly a month later is a success story. A forced migration that limps into production is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long does a MySQL to PostgreSQL migration take for a typical Laravel app?
&lt;/h3&gt;

&lt;p&gt;Plan for four to eight weeks end to end, with most of it in the audit and CI-fixing phases, not the data move. pgloader itself typically streams tens of gigabytes per hour depending on hardware and index counts. Your staging rehearsal gives you the real number for the window itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I keep using SQLite for local tests after moving to Postgres?
&lt;/h3&gt;

&lt;p&gt;For fast local feedback, sure, but CI must run the full suite against real PostgreSQL, permanently. SQLite hides dialect behavior like strict &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;ILIKE&lt;/code&gt;, and boolean handling, which is exactly the class of bug this migration introduces. Postgres-in-CI is the safety net; keep it forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Eloquent just work on PostgreSQL?
&lt;/h3&gt;

&lt;p&gt;Eloquent and the query builder support Postgres as a first-class driver, so standard &lt;code&gt;where&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, &lt;code&gt;upsert()&lt;/code&gt;, and pagination calls translate correctly. Every &lt;code&gt;DB::raw&lt;/code&gt;, &lt;code&gt;whereRaw&lt;/code&gt;, &lt;code&gt;orderByRaw&lt;/code&gt;, and &lt;code&gt;selectRaw&lt;/code&gt; in your codebase is on you, which is why the grep audit comes before everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the single most common post-migration bug?
&lt;/h3&gt;

&lt;p&gt;Case sensitivity. MySQL's default collations compare strings case-insensitively; Postgres does not. Logins, email uniqueness, and search boxes all quietly change behavior. Audit every &lt;code&gt;LIKE&lt;/code&gt;, add &lt;code&gt;lower()&lt;/code&gt; unique indexes where MySQL's collation was doing invisible work, and test authentication flows explicitly during the smoke test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I migrate MySQL to MariaDB instead as a smaller step?
&lt;/h3&gt;

&lt;p&gt;If your pain is licensing or minor feature gaps, maybe, since MariaDB is a much smaller jump. But it won't give you transactional DDL, JSONB, or partial indexes; those are the Postgres-specific reasons to migrate. If your driver list looks like this post's, MariaDB just postpones the same project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;You don't need to commit to anything today. The first step is cheap and reversible: run the grep audit from this post against your codebase and count the raw SQL you'd have to touch. If the inventory is short and the reasons-to-switch list resonated, spin up a Postgres server next to your MySQL one, point a staging copy at it, and let pgloader and your CI pipeline tell you how hard this migration really is for &lt;em&gt;your&lt;/em&gt; app. The data will make the decision more honestly than any blog post can, ours included.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>postgres</category>
      <category>mysql</category>
      <category>migration</category>
    </item>
    <item>
      <title>Database Connection Pooling for Laravel: When You Need It and How to Set It Up</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:03:03 +0000</pubDate>
      <link>https://dev.to/deploynix/database-connection-pooling-for-laravel-when-you-need-it-and-how-to-set-it-up-3o77</link>
      <guid>https://dev.to/deploynix/database-connection-pooling-for-laravel-when-you-need-it-and-how-to-set-it-up-3o77</guid>
      <description>&lt;p&gt;It usually happens during a traffic spike, a deploy, or a queue backlog you're frantically draining. The app that ran fine for months suddenly starts throwing &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; on every third request, and your error tracker lights up while you're trying to figure out what changed. Nothing changed, really. You just crossed a line that was always there: PostgreSQL's default cap of 100 connections, or MySQL's 151. Every Laravel app running on more than one server is quietly walking toward that line.&lt;/p&gt;

&lt;p&gt;The frustrating part is that connection exhaustion looks like a database problem but is really an architecture problem. PHP-FPM doesn't share database connections between workers the way a JVM app shares a pool. Every FPM worker opens its own connection for the duration of each request. Multiply that across app servers, queue workers, Horizon, the scheduler, and maybe Octane and Reverb, and the numbers get big fast, even when your actual query load is modest.&lt;/p&gt;

&lt;p&gt;Connection pooling, via PgBouncer for PostgreSQL or ProxySQL for MySQL, is the standard fix. But in our experience it's reached for too early about as often as it's reached for too late. Pooling adds a moving part, and transaction-mode pooling in particular breaks a handful of things Laravel developers use without thinking about them, like advisory locks and session variables.&lt;/p&gt;

&lt;p&gt;This post walks through the whole decision: why Laravel apps hit connection limits, what to fix before you install a pooler, when pooling genuinely becomes the answer, and how to set up PgBouncer end-to-end on Ubuntu 24.04 without breaking your migrations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- PHP-FPM gives every worker its own DB connection, so connection count scales with worker count, not query load. - PostgreSQL forks a process per connection (historically ~5-10MB+ each) and defaults to 100 max connections (PostgreSQL docs). - Fix pm.max_children, idle transactions, and slow queries before installing a pooler. - PgBouncer in transaction mode is the right default for multi-server Postgres setups; ProxySQL matters later for MySQL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Do Laravel Apps Run Out of Database Connections?
&lt;/h2&gt;

&lt;p&gt;The root cause is PHP's execution model. A Java or Go application runs as one long-lived process that maintains a small internal pool of connections, maybe 10 or 20, shared across thousands of concurrent requests. PHP-FPM works differently: each worker process handles one request at a time, opens its own database connection (or reuses a persistent one), and holds it for the entire request. There is no application-level pool. Twenty busy FPM workers means twenty database connections, full stop.&lt;/p&gt;

&lt;p&gt;That model is fine on a single small server. It stops being fine the moment you scale horizontally, because every process you add anywhere in your stack brings its own connection with it. Have you ever actually added up everything that connects to your database? Most teams haven't until the day it fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Connection Multiplication Problem
&lt;/h3&gt;

&lt;p&gt;Here's the math for a fairly ordinary two-app-server Laravel setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App server 1:  pm.max_children = 30      → up to 30 connections
App server 2:  pm.max_children = 30      → up to 30 connections
Worker server: Horizon, 20 processes     → 20 connections (held constantly)
Scheduler:     overlapping tasks         → 2-5 connections in bursts
Reverb:        websocket server          → 1-2 connections
Deploy window: migrations, cache warm    → 2-3 connections
                                          -------------------------------
Practical peak:                            ~85-90 connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against PostgreSQL's default &lt;code&gt;max_connections = 100&lt;/code&gt;, that leaves almost no headroom. One traffic spike that pushes both FPM pools to their limits, one queue backlog that makes every Horizon worker busy simultaneously, and you're at the ceiling. The next connection attempt gets &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt;, and it doesn't discriminate: your health checks, your deploy migrations, and your &lt;code&gt;psql&lt;/code&gt; debugging session all get refused too.&lt;/p&gt;

&lt;p&gt;Note what's absent from that math: query volume. You can hit this wall at 50 requests per second or 500. Connection count tracks process count, which is why the problem tends to appear right after you split your stack across servers. We cover that transition in detail in &lt;a href="https://deploynix.io/blog/building-a-multi-server-laravel-architecture-when-and-how-to-split-your-stack" rel="noopener noreferrer"&gt;our guide to multi-server Laravel architecture&lt;/a&gt;, and connection budgeting is one of the first things it forces you to think about.&lt;/p&gt;

&lt;p&gt;Queue workers deserve special mention because they're the silent consumers. An FPM worker releases its connection between requests, at least logically, but a queue worker is a long-running process that holds its connection for hours. Twenty Horizon processes are twenty connections around the clock, even at 3 a.m. when they're processing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Are PostgreSQL Connections So Expensive?
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's connection cost is the reason this topic is mostly a Postgres conversation. Postgres forks a dedicated operating system process for every single connection. Each backend process has historically consumed roughly 5-10MB or more of memory depending on workload, &lt;code&gt;work_mem&lt;/code&gt; settings, and what it has touched, and the fork itself costs real CPU time. This per-process design is also why the default &lt;code&gt;max_connections&lt;/code&gt; is a conservative 100 (&lt;a href="https://www.postgresql.org/docs/current/runtime-config-connection.html" rel="noopener noreferrer"&gt;PostgreSQL docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The process model has real advantages, isolation being the big one, but it means idle connections are not free. A Postgres server with 300 connections, of which 250 are idle, is still carrying 300 processes worth of memory and scheduler overhead. Past a few hundred connections, throughput can degrade even when most of those connections do nothing.&lt;/p&gt;

&lt;p&gt;MySQL is a different story. MySQL uses a thread per connection rather than a process, and threads are much cheaper to create and carry. The default &lt;code&gt;max_connections&lt;/code&gt; is 151, and raising it into the several-hundreds range is routine and mostly safe on reasonable hardware (&lt;a href="https://dev.mysql.com/doc/refman/8.4/en/connection-interfaces.html" rel="noopener noreferrer"&gt;MySQL docs&lt;/a&gt;). This is why MySQL shops often go years without thinking about pooling, while Postgres shops hit the wall in month six. If you're still choosing an engine, this trade-off is one of several we compare in &lt;a href="https://deploynix.io/blog/mysql-vs-mariadb-vs-postgresql-on-deploynix-which-should-you-pick" rel="noopener noreferrer"&gt;MySQL vs MariaDB vs PostgreSQL on Deploynix&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The asymmetry shapes the advice in the rest of this post: for PostgreSQL, pooling is a when, not an if, once you run multiple servers. For MySQL, it's a tool you reach for later, and often for reasons beyond raw connection counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should You Try Before Installing a Pooler?
&lt;/h2&gt;

&lt;p&gt;A pooler is another daemon to configure, monitor, and debug. Before adding one, it's worth an hour checking whether you have a real capacity problem or just waste. We've found that maybe half of "we need PgBouncer" conversations end with one of the four fixes below instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Right-Size pm.max_children
&lt;/h3&gt;

&lt;p&gt;FPM worker counts are the biggest lever, and they're frequently set by copy-paste rather than measurement. If your app server has &lt;code&gt;pm.max_children = 50&lt;/code&gt; but never serves more than 15 concurrent requests, you've reserved 50 potential database connections to cover load that needs 15. Size FPM from actual memory usage and real concurrency, not from a blog post's default. Our &lt;a href="https://deploynix.io/blog/tuning-php-fpm-for-laravel-workers-memory-and-process-management" rel="noopener noreferrer"&gt;PHP-FPM tuning guide&lt;/a&gt; walks through the measurement process; the connection budget falls out of it almost for free.&lt;/p&gt;

&lt;p&gt;The same logic applies to queue workers. Ten Horizon processes that are busy 5% of the time are ten permanent connections you could cut to four with no throughput loss.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raise max_connections, But Do the Memory Math
&lt;/h3&gt;

&lt;p&gt;Raising &lt;code&gt;max_connections&lt;/code&gt; is legitimate as a first response, if you do it with a calculator rather than hope. The rough budget for Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server RAM:               8 GB
shared_buffers:           2 GB
OS + page cache reserve:  2 GB
Remaining for backends:   4 GB

At ~10 MB per active backend:
4096 MB / 10 MB ≈ 400 theoretical max
Safe setting with margin:  max_connections = 200-250
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doubling from 100 to 200 on an 8GB dedicated database server is reasonable and buys real headroom. Going from 100 to 1,000 is not; you'd be one busy afternoon away from the OOM killer choosing your database as its victim. If the math says you need 500+ connections on Postgres, that's the signal that you've outgrown raising limits and need a pooler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kill Idle-in-Transaction Leaks
&lt;/h3&gt;

&lt;p&gt;Connections stuck in &lt;code&gt;idle in transaction&lt;/code&gt; are pure waste with extra damage: they hold locks and block vacuum. They usually come from application code that opens a transaction and then does slow work, an HTTP call, a file upload, a long loop, before committing. Find them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state_change&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;stuck_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle in transaction'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;stuck_for&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set a server-side backstop so leaks can't accumulate: &lt;code&gt;idle_in_transaction_session_timeout = '60s'&lt;/code&gt;. In Laravel code, the fix is keeping &lt;code&gt;DB::transaction()&lt;/code&gt; closures tight. Do slow work outside the transaction, then transact only around the writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix the Slow Queries Holding Connections
&lt;/h3&gt;

&lt;p&gt;Connection demand is concurrency times duration. A query that takes 2 seconds holds a connection 40 times longer than one that takes 50ms, so a handful of slow queries can inflate your concurrent connection count dramatically at the same request rate. Before buying capacity for slow queries, make them fast. We've written a full workflow for &lt;a href="https://deploynix.io/blog/finding-and-fixing-slow-queries-in-laravel-before-they-hit-production" rel="noopener noreferrer"&gt;finding and fixing slow queries before they hit production&lt;/a&gt;, and for MySQL specifically, &lt;a href="https://deploynix.io/blog/mysql-indexing-strategies-every-laravel-developer-should-know" rel="noopener noreferrer"&gt;proper indexing&lt;/a&gt; is usually where the biggest wins hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Does Pooling Become the Actual Answer?
&lt;/h2&gt;

&lt;p&gt;Once you've right-sized workers and cleaned up leaks, the remaining question is structural: does your legitimate peak process count exceed what your database can comfortably hold? For PostgreSQL, that tends to happen at two-plus app servers. Here's our honest read on when to bother:&lt;/p&gt;

&lt;p&gt;Your setup&lt;/p&gt;

&lt;p&gt;Postgres&lt;/p&gt;

&lt;p&gt;MySQL/MariaDB&lt;/p&gt;

&lt;p&gt;1 app server, no separate workers&lt;/p&gt;

&lt;p&gt;No pooler. Tune FPM, defaults are fine&lt;/p&gt;

&lt;p&gt;No pooler&lt;/p&gt;

&lt;p&gt;1 app server + Horizon worker server&lt;/p&gt;

&lt;p&gt;Probably not yet; raise &lt;code&gt;max_connections&lt;/code&gt; to ~200&lt;/p&gt;

&lt;p&gt;No pooler&lt;/p&gt;

&lt;p&gt;2-3 app servers + workers&lt;/p&gt;

&lt;p&gt;Yes, PgBouncer in transaction mode&lt;/p&gt;

&lt;p&gt;Usually still no; raise &lt;code&gt;max_connections&lt;/code&gt;, consider thread pool&lt;/p&gt;

&lt;p&gt;4+ app servers, autoscaling, or Octane fleet&lt;/p&gt;

&lt;p&gt;Definitely, non-negotiable&lt;/p&gt;

&lt;p&gt;ProxySQL worth evaluating (pooling + read/write split)&lt;/p&gt;

&lt;p&gt;Serverless/ephemeral compute against Postgres&lt;/p&gt;

&lt;p&gt;Always, from day one&lt;/p&gt;

&lt;p&gt;ProxySQL or managed proxy from day one&lt;/p&gt;

&lt;p&gt;The other trigger is connection churn rather than count. Because Postgres forks per connection, an app opening and closing hundreds of connections per second burns meaningful CPU on connection setup alone. PgBouncer turns that churn into cheap reuse of a small warm pool. FPM without persistent connections generates exactly this pattern under load.&lt;/p&gt;

&lt;p&gt;What a pooler buys you, concretely: your 90 client connections from the earlier math collapse onto perhaps 20-25 actual Postgres backends, because at any instant only a fraction of those clients are mid-query. The database does the same work with a quarter of the processes, and you gain enormous headroom for adding app servers without touching &lt;code&gt;max_connections&lt;/code&gt; again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which PgBouncer Pooling Mode Should You Use?
&lt;/h2&gt;

&lt;p&gt;PgBouncer (&lt;a href="https://www.pgbouncer.org/" rel="noopener noreferrer"&gt;pgbouncer.org&lt;/a&gt;) sits between your app and Postgres, maintaining a small pool of real server connections and multiplexing client connections onto them. How aggressively it multiplexes depends on the pooling mode, and choosing the mode is the single most important configuration decision.&lt;/p&gt;

&lt;p&gt;Mode&lt;/p&gt;

&lt;p&gt;Server connection assigned for&lt;/p&gt;

&lt;p&gt;Multiplexing benefit&lt;/p&gt;

&lt;p&gt;Session state safe?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;session&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The client's entire connection lifetime&lt;/p&gt;

&lt;p&gt;Minimal (mostly saves connect/fork cost)&lt;/p&gt;

&lt;p&gt;Yes, everything works&lt;/p&gt;

&lt;p&gt;&lt;code&gt;transaction&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One transaction at a time&lt;/p&gt;

&lt;p&gt;High, the sweet spot&lt;/p&gt;

&lt;p&gt;No, breaks session-level features&lt;/p&gt;

&lt;p&gt;&lt;code&gt;statement&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One statement at a time&lt;/p&gt;

&lt;p&gt;Maximum&lt;/p&gt;

&lt;p&gt;No, even multi-statement transactions break&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Session mode is fully transparent; nothing in your app needs auditing. Transaction mode delivers the actual consolidation, letting hundreds of clients share tens of backends. Statement mode squeezes out slightly more sharing for pure autocommit workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Session mode fits apps that need session features (LISTEN/NOTIFY, advisory locks) but want cheaper connection setup. Transaction mode is the right default for Laravel behind FPM, and it's what the rest of this guide assumes. Statement mode is a niche tool; almost no Laravel app should use it, since it forbids explicit transactions entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Transaction mode means consecutive queries from the same client may run on different server connections. Anything that assumes "my connection remembers me" breaks: &lt;code&gt;SET&lt;/code&gt; session variables, advisory locks held across transactions, LISTEN/NOTIFY, and, before PgBouncer 1.21, server-side prepared statements. PgBouncer 1.21+ added protocol-level prepared statement tracking, which removed the biggest Laravel pain point. More on the audit below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Set Up PgBouncer on Ubuntu 24.04?
&lt;/h2&gt;

&lt;p&gt;The recommended placement is on the database server itself, next to Postgres, listening on port 6432. One pooler, one place to configure and monitor, and it protects the database from every current and future client. (We'll discuss the alternative, a PgBouncer per app server, in the Deploynix section.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Install and Authenticate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; pgbouncer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ubuntu 24.04 ships PgBouncer 1.21+, so you get native prepared statement support. Authentication is the fiddly part. PgBouncer authenticates clients itself, then logs into Postgres on their behalf. With modern Postgres defaulting to SCRAM-SHA-256, you have two workable options.&lt;/p&gt;

&lt;p&gt;Option one, &lt;code&gt;auth_file&lt;/code&gt;: put the SCRAM verifier (not the plaintext password) in &lt;code&gt;/etc/pgbouncer/userlist.txt&lt;/code&gt;. Extract it from Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passwd&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_shadow&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"laravel" "SCRAM-SHA-256$4096:...paste the full verbatim value..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option two, &lt;code&gt;auth_query&lt;/code&gt;, which scales better with multiple users: create a dedicated &lt;code&gt;pgbouncer&lt;/code&gt; role in Postgres with a SECURITY DEFINER function that looks up verifiers, and let PgBouncer query for them on demand. The &lt;a href="https://www.pgbouncer.org/config.html" rel="noopener noreferrer"&gt;PgBouncer docs&lt;/a&gt; have the canonical function. For a single-app server with one database user, &lt;code&gt;auth_file&lt;/code&gt; is simpler and fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pgbouncer.ini That Matters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[databases]&lt;/span&gt;
&lt;span class="py"&gt;myapp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;host=127.0.0.1 port=5432 dbname=myapp&lt;/span&gt;

&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;
&lt;span class="py"&gt;listen_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10.0.0.5          ; private network IP of the DB server&lt;/span&gt;
&lt;span class="py"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6432&lt;/span&gt;
&lt;span class="py"&gt;auth_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
&lt;span class="py"&gt;auth_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/pgbouncer/userlist.txt&lt;/span&gt;

&lt;span class="py"&gt;pool_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;20          ; real Postgres backends per user/db pair&lt;/span&gt;
&lt;span class="py"&gt;min_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;reserve_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5           ; emergency backends under burst&lt;/span&gt;
&lt;span class="py"&gt;reserve_pool_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;500           ; total app-side connections accepted&lt;/span&gt;
&lt;span class="py"&gt;max_prepared_statements&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;200   ; PgBouncer 1.21+ prepared stmt tracking&lt;/span&gt;

&lt;span class="py"&gt;log_connections&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;log_disconnections&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sizing logic: &lt;code&gt;default_pool_size&lt;/code&gt; is how many actual Postgres backends this pool may use, so it must fit within &lt;code&gt;max_connections&lt;/code&gt; alongside anything else connecting directly. Twenty backends comfortably serve the 90-client scenario from earlier because clients only occupy a backend while a transaction is in flight. &lt;code&gt;max_client_conn = 500&lt;/code&gt; gives your FPM fleet room to grow several-fold before you touch this file again. Start with &lt;code&gt;default_pool_size&lt;/code&gt; around 20-25 and adjust from &lt;code&gt;SHOW POOLS&lt;/code&gt; data, not guesswork.&lt;/p&gt;

&lt;p&gt;Restart and verify: &lt;code&gt;sudo systemctl restart pgbouncer&lt;/code&gt;, then connect through it with &lt;code&gt;psql -h 10.0.0.5 -p 6432 -U laravel myapp&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Point Laravel at Port 6432
&lt;/h3&gt;

&lt;p&gt;The application change is deliberately boring: same driver, different port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/database.php&lt;/span&gt;
&lt;span class="s1"&gt;'pgsql'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pgsql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'6432'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// PgBouncer, not Postgres&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'myapp'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'search_path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'sslmode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'prefer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set &lt;code&gt;DB_PORT=6432&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; on every app and worker server. On PgBouncer versions older than 1.21 you'd also add &lt;code&gt;PDO::ATTR_EMULATE_PREPARES =&amp;gt; true&lt;/code&gt; to the connection's &lt;code&gt;options&lt;/code&gt; array to sidestep server-side prepared statements; on 1.21+ with &lt;code&gt;max_prepared_statements&lt;/code&gt; set, you don't need it.&lt;/p&gt;

&lt;p&gt;One useful escape hatch: keep a second Laravel connection named &lt;code&gt;pgsql_direct&lt;/code&gt; pointing at port 5432. Use it for the rare session-dependent work below.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Breaks in Transaction Mode: The Laravel Audit
&lt;/h3&gt;

&lt;p&gt;Before flipping production traffic, grep your codebase for the session-state features transaction pooling breaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advisory locks. &lt;code&gt;pg_advisory_lock()&lt;/code&gt; acquires a lock tied to a session; in transaction mode your next query may land on a different backend that doesn't hold it. Laravel land examples: some concurrency guards and packages use advisory locks, and schema-migration lock strategies on Postgres can too. Use &lt;code&gt;pg_advisory_xact_lock()&lt;/code&gt; (transaction-scoped, safe) or run that code on the direct connection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET&lt;/code&gt; session variables. Anything doing &lt;code&gt;DB::unprepared("SET statement_timeout = ...")&lt;/code&gt; or setting &lt;code&gt;search_path&lt;/code&gt; at runtime is configuring a backend another request will inherit, or losing the setting immediately. Use &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction, or set values per-role with &lt;code&gt;ALTER ROLE ... SET&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;LISTEN/NOTIFY. &lt;code&gt;LISTEN&lt;/code&gt; is inherently session-bound. Any pgsql pub/sub listener must connect directly to 5432. This is a per-process daemon anyway, so pooling it gains nothing.&lt;/li&gt;
&lt;li&gt;Migrations, generally. We simply run &lt;code&gt;php artisan migrate&lt;/code&gt; against the direct connection. Migrations are one connection during a deploy; they don't need pooling, and DDL plus session-dependent lock behavior is the exact category of thing you don't want multiplexed.&lt;/li&gt;
&lt;li&gt;Prepared statements, only if you're on PgBouncer &amp;lt; 1.21. Upgrade instead of working around it; the emulation flag changes PDO behavior subtly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that audit comes back clean, and for most Laravel CRUD apps it does, transaction mode is transparent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About MySQL? ProxySQL and Thread Pools
&lt;/h2&gt;

&lt;p&gt;MySQL's cheap threads mean the "too many clients" cliff sits much further out. Raising &lt;code&gt;max_connections&lt;/code&gt; from 151 to 500 on a dedicated MySQL box with adequate RAM is a config change, not a project, and it carries nothing like Postgres's per-process penalty. Our honest guidance: most MySQL-backed Laravel apps under roughly 4-5 servers need no middleware at all.&lt;/p&gt;

&lt;p&gt;Two situations change that. First, genuinely large fleets, where thousands of connections start to hurt even MySQL through thread scheduling overhead and per-thread buffers. Second, and more commonly, you want capabilities beyond pooling: query routing, read/write splitting to replicas, or query-level failover. That's ProxySQL's (&lt;a href="https://proxysql.com/" rel="noopener noreferrer"&gt;proxysql.com&lt;/a&gt;) actual selling point, connection multiplexing comes along for the ride.&lt;/p&gt;

&lt;p&gt;ProxySQL is configured through a SQL admin interface rather than a flat file. The minimum viable setup, with a primary in hostgroup 10 and read replicas in hostgroup 20:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- via mysql -u admin -padmin -h 127.0.0.1 -P 6032&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;mysql_servers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostgroup_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'10.0.0.5'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3306&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'10.0.0.6'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3306&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;mysql_users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_hostgroup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'laravel'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Route SELECTs to replicas, keep locking reads on the primary&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;mysql_query_rules&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination_hostgroup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^SELECT.*FOR UPDATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^SELECT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;SERVERS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;RUNTIME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;SAVE&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;SERVERS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;DISK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;USERS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;RUNTIME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="n"&gt;SAVE&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;USERS&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;DISK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;RUNTIME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;SAVE&lt;/span&gt; &lt;span class="n"&gt;MYSQL&lt;/span&gt; &lt;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;DISK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel then points at ProxySQL on port 6033 exactly as it pointed at PgBouncer on 6432. Note that Laravel's own &lt;code&gt;read&lt;/code&gt;/&lt;code&gt;write&lt;/code&gt; config arrays can do read/write splitting in-app; ProxySQL does it transparently for every client and adds failover, but if splitting is your only goal, try the framework feature first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Multiplexing, read/write splitting, per-query routing rules, connection admission control, and rich stats tables, all hot-reloadable at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; MySQL fleets of 4+ app servers, replica setups where you want routing outside the application, and teams that need failover handling between the app and the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; It's a substantially more complex system than PgBouncer, with its own database of config. Multiplexing silently disables itself for sessions using certain features (user variables, &lt;code&gt;SQL_CALC_FOUND_ROWS&lt;/code&gt;, transactions in progress), which can surprise you in the stats. Don't deploy it for pooling alone unless connection counts are demonstrably hurting.&lt;/p&gt;

&lt;p&gt;Also worth knowing: MariaDB ships a built-in thread pool (&lt;code&gt;thread_handling = pool-of-threads&lt;/code&gt;), and Percona Server offers the same, which decouples client connections from worker threads inside the server itself. For "many mostly-idle connections" on MariaDB, enabling the thread pool is a one-line change that postpones any proxy conversation considerably.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do Octane and Long-Lived Workers Change the Math?
&lt;/h2&gt;

&lt;p&gt;Laravel Octane inverts the FPM equation. Octane workers are long-lived processes that boot the framework once, and each worker holds its database connection persistently across requests rather than reconnecting per request. Connection churn drops to nearly zero, which is a genuine win, but the connections become permanent occupants, exactly like queue workers.&lt;/p&gt;

&lt;p&gt;The math shifts accordingly. Sixteen Octane workers per server across four servers is 64 permanent connections before you count Horizon. The good news: Octane worker counts are usually far lower than equivalent FPM &lt;code&gt;max_children&lt;/code&gt;, because each worker is faster. The subtle news: because connections are held while idle, Postgres carries their full process cost around the clock, and PgBouncer's transaction mode helps less than you'd expect for the web tier, since a small warm set of app-held connections was sort of the goal anyway.&lt;/p&gt;

&lt;p&gt;Where pooling still earns its keep with Octane: consolidating the web fleet plus queue workers plus scheduler plus deploy tasks behind one budget, and protecting the database when a worker restart storm (deploys, &lt;code&gt;octane:reload&lt;/code&gt;) reconnects everything at once. One caveat we've learned to respect: combining Octane's persistent connections with transaction-mode PgBouncer means a session-state mistake persists across many requests instead of one, so the transaction-mode audit above matters more, not less, under Octane.&lt;/p&gt;

&lt;p&gt;Queue workers behave the same either way: one held connection per process, forever. Budget them explicitly. If you're planning worker counts alongside app growth, our &lt;a href="https://deploynix.io/blog/scaling-laravel-from-1-to-100000-users-a-deploynix-infrastructure-playbook" rel="noopener noreferrer"&gt;scaling playbook from 1 to 100,000 users&lt;/a&gt; includes connection budgets at each stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does This Look on Deploynix?
&lt;/h2&gt;

&lt;p&gt;Deploynix provisions a dedicated database server type (MySQL, MariaDB, or PostgreSQL) alongside app and worker server types, on DigitalOcean, Vultr, Linode, Hetzner, AWS, or your own custom servers. The multi-server pattern that triggers this whole topic, an FPM fleet multiplying connections against one database box, is exactly the architecture the platform encourages, so it's worth being explicit about where the pooler fits.&lt;/p&gt;

&lt;p&gt;Our recommendation is to install PgBouncer on the database server itself. All servers in a Deploynix architecture talk over private networking, and the platform's firewall rules already lock database access down to your app servers' private IPs; pointing those same rules at 6432 instead of 5432 is the only change. One pooler on the DB box gives you a single pool to size against &lt;code&gt;max_connections&lt;/code&gt;, a single &lt;code&gt;SHOW POOLS&lt;/code&gt; to watch, and automatic coverage for every app server you add later. Since the database server type gives Postgres the whole machine, PgBouncer's own footprint (a few tens of MB) is negligible next to the backend processes it eliminates.&lt;/p&gt;

&lt;p&gt;The alternative, a PgBouncer instance on each app server, has real advocates: connections from FPM to a local pooler over a Unix socket are essentially free, and there's no extra network hop. The cost is N poolers whose &lt;code&gt;default_pool_size&lt;/code&gt; values must jointly fit the database's limits, and re-doing that arithmetic every time the fleet grows. For most teams the operational simplicity of one pooler wins; consider per-app-server pooling only when you're chasing the last milliseconds of connection latency.&lt;/p&gt;

&lt;p&gt;Two platform details make the rollout less nervy. Queue workers are managed through the UI, so counting your permanent connection consumers is a matter of reading the workers page rather than grepping supervisor configs across servers. And server monitoring shows memory and load on the database box, so you can watch Postgres's footprint drop as backends consolidate after the cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Monitor the Pool Once It's Running?
&lt;/h2&gt;

&lt;p&gt;An unmonitored pooler just moves the outage one layer up: instead of &lt;code&gt;too many clients&lt;/code&gt;, requests queue silently inside PgBouncer. Connect to its admin console and make &lt;code&gt;SHOW POOLS&lt;/code&gt; part of your routine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; 10.0.0.5 &lt;span class="nt"&gt;-p&lt;/span&gt; 6432 &lt;span class="nt"&gt;-U&lt;/span&gt; pgbouncer pgbouncer &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SHOW POOLS;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database | user    | cl_active | cl_waiting | sv_active | sv_idle | maxwait
----------+---------+-----------+------------+-----------+---------+---------
 myapp    | laravel |        84 |          0 |        14 |       6 |       0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns that matter: &lt;code&gt;cl_active&lt;/code&gt; is connected clients, &lt;code&gt;sv_active&lt;/code&gt;/&lt;code&gt;sv_idle&lt;/code&gt; are real Postgres backends in use and warm, and the two saturation signals are &lt;code&gt;cl_waiting&lt;/code&gt; and &lt;code&gt;maxwait&lt;/code&gt;. If &lt;code&gt;cl_waiting&lt;/code&gt; is persistently above zero and &lt;code&gt;maxwait&lt;/code&gt; climbs past a few hundred milliseconds, clients are queuing for backends: raise &lt;code&gt;default_pool_size&lt;/code&gt; (checking &lt;code&gt;max_connections&lt;/code&gt; headroom first) or go fix whatever made transactions slower, because pool saturation is very often a slow-query symptom wearing a new costume.&lt;/p&gt;

&lt;p&gt;Alert on three things: &lt;code&gt;cl_waiting &amp;gt; 0&lt;/code&gt; sustained for more than a minute, &lt;code&gt;maxwait&lt;/code&gt; above one second, and total client connections approaching &lt;code&gt;max_client_conn&lt;/code&gt;. &lt;code&gt;SHOW STATS&lt;/code&gt; adds per-database query and transaction throughput with average times, which is useful for spotting the moment average transaction duration creeps up. On the ProxySQL side, the &lt;code&gt;stats_mysql_connection_pool&lt;/code&gt; table plays the same role. Feed whichever you use into the same dashboards as your server metrics; pool saturation next to CPU and memory graphs tells you immediately whether the bottleneck is the pool or the database behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Laravel have built-in connection pooling?
&lt;/h3&gt;

&lt;p&gt;No. Under PHP-FPM, each worker opens its own connection per request; there's no shared pool to configure in the framework. Octane holds one persistent connection per long-lived worker, which reduces churn but isn't multiplexed pooling. Real pooling for PHP means external middleware: PgBouncer for PostgreSQL, ProxySQL for MySQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should queue workers and Horizon connect through PgBouncer too?
&lt;/h3&gt;

&lt;p&gt;Yes, with one caveat. Workers hold connections permanently, so routing them through transaction-mode PgBouncer lets dozens of mostly-idle workers share a handful of backends, which is a bigger win than the web tier gets. The caveat: jobs using advisory locks or other session state need the transaction-mode audit, or a direct connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run migrations through PgBouncer in transaction mode?
&lt;/h3&gt;

&lt;p&gt;Often it works, but we don't recommend it. Migration tooling can rely on advisory locks and session-scoped behavior that transaction pooling breaks in confusing ways. Define a second Laravel connection on port 5432 and run &lt;code&gt;php artisan migrate --database=pgsql_direct&lt;/code&gt;. It's one connection during deploys; pooling it saves nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is pgbouncer worth it on a single-server setup?
&lt;/h3&gt;

&lt;p&gt;Usually not. With app and database on one box, tuned &lt;code&gt;pm.max_children&lt;/code&gt; plus default &lt;code&gt;max_connections&lt;/code&gt; rarely conflict, and a Unix-socket connection to local Postgres is already cheap. The exception is heavy connection churn under high traffic, where even a local session-mode PgBouncer saves fork overhead. Fix FPM sizing and slow queries first.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the MySQL equivalent of "too many clients already"?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ERROR 1040 (HY000): Too many connections&lt;/code&gt;, hit when you exceed &lt;code&gt;max_connections&lt;/code&gt; (default 151). The first response differs from Postgres: because MySQL threads are cheap, raising the limit to 300-500 on adequate RAM is usually the right move, before considering MariaDB's thread pool or ProxySQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;The one-hour version of this post: add up your real connection consumers (FPM children × app servers, plus every queue worker, plus Octane and Reverb if you run them), compare the total against your database's &lt;code&gt;max_connections&lt;/code&gt;, and check &lt;code&gt;pg_stat_activity&lt;/code&gt; for idle-in-transaction waste. If your honest peak sits under 60% of the limit, tune and move on. If it doesn't, or you're about to add app servers, put PgBouncer in transaction mode on the database box, run the session-state audit, and point &lt;code&gt;DB_PORT&lt;/code&gt; at 6432.&lt;/p&gt;

&lt;p&gt;Do the counting exercise this week, before growth does it for you at 2 a.m. And if the numbers tell you it's time to split your stack properly, start with our guide to &lt;a href="https://deploynix.io/blog/building-a-multi-server-laravel-architecture-when-and-how-to-split-your-stack" rel="noopener noreferrer"&gt;building a multi-server Laravel architecture&lt;/a&gt;; the connection budget you just calculated is the first input it asks for.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>database</category>
      <category>postgres</category>
      <category>scaling</category>
    </item>
    <item>
      <title>Configuring Valkey Persistence: RDB vs. AOF for Laravel Caching, Sessions, and Queues</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:03:03 +0000</pubDate>
      <link>https://dev.to/deploynix/configuring-valkey-persistence-rdb-vs-aof-for-laravel-caching-sessions-and-queues-m8d</link>
      <guid>https://dev.to/deploynix/configuring-valkey-persistence-rdb-vs-aof-for-laravel-caching-sessions-and-queues-m8d</guid>
      <description>&lt;p&gt;A few months ago we helped a team debug a mystery. Their host had rebooted a VPS for maintenance at 3 a.m. The server came back cleanly, Nginx was up, PHP-FPM was up, Valkey was up. Everything looked healthy. Except 4,000 queued jobs, invoices, welcome emails, webhook deliveries, had simply ceased to exist. No errors, no failed_jobs rows, no trace. Valkey had been running with persistence disabled, so every job sitting in those queue lists lived only in RAM. The reboot didn't crash anything. It just quietly forgot.&lt;/p&gt;

&lt;p&gt;That's the thing about the persistence decision: most Laravel teams never consciously make it. You install Valkey (or you used to install Redis), point &lt;code&gt;CACHE_STORE&lt;/code&gt;, &lt;code&gt;SESSION_DRIVER&lt;/code&gt;, and &lt;code&gt;QUEUE_CONNECTION&lt;/code&gt; at it, and move on. The defaults you inherited, often just RDB snapshots, or nothing at all inside a container, silently become your durability policy for three workloads with completely different requirements. Losing cache is a shrug. Losing sessions is an annoyance. Losing queues is data loss, full stop.&lt;/p&gt;

&lt;p&gt;The timing matters more in 2026 than it used to. Redis 7.2, the last BSD-licensed release line, reached end-of-life on February 28, 2026 (&lt;a href="https://dev.to/synsun/redis-vs-valkey-in-2026-what-the-license-fork-actually-changed-1kni"&gt;dev.to fork retrospective&lt;/a&gt;), and Valkey is now the default in-memory store on AWS ElastiCache. Valkey 9.1, released in May 2026, benchmarks at roughly 2.1 million requests per second with a further ~10% memory reduction over the previous release (&lt;a href="https://danubedata.ro/blog/redis-vs-valkey-vs-dragonfly-2026" rel="noopener noreferrer"&gt;DanubeData&lt;/a&gt;). Performance is a solved problem. Durability is the part you still have to configure yourself, and this post walks through exactly how.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- Persistence needs differ per workload: cache can lose everything, queues can lose nothing. - RDB snapshots are cheap but lose minutes of writes; AOF with &lt;code&gt;appendfsync everysec&lt;/code&gt; bounds loss to ~1 second. - Hybrid mode (&lt;code&gt;aof-use-rdb-preamble yes&lt;/code&gt;) is the modern default for anything durable, and it's on by default in Valkey (Valkey docs). - Run separate Valkey instances for cache and queues, with &lt;code&gt;allkeys-lru&lt;/code&gt; for cache and &lt;code&gt;noeviction&lt;/code&gt; for queues. - Redis 7.2 hit end-of-life February 2026, so this config now lives in &lt;code&gt;valkey.conf&lt;/code&gt; for most of us (dev.to).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What's Actually at Stake When Valkey Loses Its Data?
&lt;/h2&gt;

&lt;p&gt;The blast radius of losing Valkey's dataset depends entirely on what Laravel stores in it. A cold cache costs you seconds of elevated database load. A lost queue costs you real work that customers paid for. Before touching a single config directive, you need to sort your keys into "annoying to lose" versus "unacceptable to lose", because the right persistence mode follows directly from that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure cache.&lt;/strong&gt; If &lt;code&gt;CACHE_STORE=redis&lt;/code&gt; and the instance vanishes, Laravel simply regenerates every cache entry on the next request. Your database eats a thundering herd for a few minutes, response times spike, and then everything settles. For most applications this is a non-event. Persistence for a pure cache is optional, and arguably counterproductive on small servers, because you're paying disk I/O to protect data that was designed to be disposable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sessions.&lt;/strong&gt; With &lt;code&gt;SESSION_DRIVER=redis&lt;/code&gt;, losing the dataset logs out every user simultaneously. Nobody's data is corrupted, but every cart is emptied, every multi-step form is reset, and your support inbox knows about it within minutes. This sits in the middle: not catastrophic, but embarrassing enough that some durability is worth a little disk I/O.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queues.&lt;/strong&gt; This is the horror story from the intro. Laravel queues on the Redis driver are lists and sorted sets: &lt;code&gt;queues:default&lt;/code&gt;, plus the delayed and reserved sorted sets that hold retries and scheduled jobs. There is no upstream copy. If Valkey forgets them, the jobs are gone, and unlike an exception in a worker, nothing lands in &lt;code&gt;failed_jobs&lt;/code&gt;, so &lt;a href="https://deploynix.io/blog/debugging-laravel-queue-failures-in-production-without-losing-messages" rel="noopener noreferrer"&gt;debugging the disappearance&lt;/a&gt; is miserable because there's literally nothing left to debug. Queues demand the strongest persistence you can afford.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizon metadata.&lt;/strong&gt; Horizon stores its dashboard metrics, job history, and supervisor state in the same store as your queues. Losing it resets your graphs and recent-jobs list, which is tolerable, but since it usually shares an instance with queue data, it inherits queue-grade persistence for free.&lt;/p&gt;

&lt;p&gt;If you're still weighing whether Valkey is the right engine for these workloads at all, we've covered &lt;a href="https://deploynix.io/blog/valkey-vs-redis-for-laravel-caching-and-queues-what-you-need-to-know" rel="noopener noreferrer"&gt;why Valkey took over from Redis for Laravel caching and queues&lt;/a&gt; separately. The short version: for persistence purposes, everything in this post behaves identically to Redis 7.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does RDB Snapshotting Work?
&lt;/h2&gt;

&lt;p&gt;RDB is Valkey's point-in-time snapshot mechanism, unchanged in concept since early Redis: at configured thresholds, Valkey forks a child process that writes the entire dataset to a compact binary file, &lt;code&gt;dump.rdb&lt;/code&gt;, while the parent keeps serving traffic (&lt;a href="https://valkey.io/docs/" rel="noopener noreferrer"&gt;Valkey docs&lt;/a&gt;). The catch is in that sentence twice. "Point-in-time" means everything written after the snapshot is unprotected. "Forks" means a memory and latency cost you must plan for.&lt;/p&gt;

&lt;p&gt;The snapshot schedule lives in &lt;code&gt;valkey.conf&lt;/code&gt; as &lt;code&gt;save&lt;/code&gt; rules, each one meaning "snapshot if at least N changes happened in M seconds":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/valkey/valkey.conf
&lt;/span&gt;
&lt;span class="c"&gt;# Snapshot after 900s if &amp;gt;=1 key changed,
# after 300s if &amp;gt;=10 changed, after 60s if &amp;gt;=10000 changed
&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;

&lt;span class="n"&gt;dbfilename&lt;/span&gt; &lt;span class="n"&gt;dump&lt;/span&gt;.&lt;span class="n"&gt;rdb&lt;/span&gt;
&lt;span class="n"&gt;dir&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;lib&lt;/span&gt;/&lt;span class="n"&gt;valkey&lt;/span&gt;

&lt;span class="c"&gt;# Abort writes if the last snapshot failed (safe default)
&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;-&lt;span class="n"&gt;writes&lt;/span&gt;-&lt;span class="n"&gt;on&lt;/span&gt;-&lt;span class="n"&gt;bgsave&lt;/span&gt;-&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;rdbcompression&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;rdbchecksum&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those defaults, your worst case is losing up to 15 minutes of writes. For a busy queue processing hundreds of jobs a minute, "up to 15 minutes" can mean thousands of jobs. That's the fundamental RDB trade: excellent operational characteristics, weak durability guarantees.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fork and Copy-on-Write Spike
&lt;/h3&gt;

&lt;p&gt;Here's the part that bites small VPSes. When Valkey forks, the child shares memory pages with the parent via copy-on-write. Pages only get duplicated when the parent modifies them during the snapshot. Under light write load that's a few megabytes. Under heavy write load, a meaningful fraction of the dataset gets copied, and in the worst case memory usage approaches double the dataset size for the duration of the snapshot.&lt;/p&gt;

&lt;p&gt;We've seen this take down a 2 GB droplet running a 1.2 GB Valkey dataset: BGSAVE fires during a traffic spike, copy-on-write balloons, the kernel OOM killer picks a victim, and the victim is Valkey itself. Two mitigations. First, set &lt;code&gt;vm.overcommit_memory = 1&lt;/code&gt; in sysctl so the fork itself never fails. Second, size the server so the dataset stays comfortably under half of RAM if you rely on RDB under write-heavy traffic, and watch &lt;code&gt;latest_fork_usec&lt;/code&gt; (more on that below) because fork time also grows linearly with dataset size and stalls the main thread while it runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Compact single-file backups that are trivial to copy off-server; fastest possible restart times since loading an RDB file is much quicker than replaying a log; minimal steady-state I/O; great fit for disaster-recovery snapshots shipped to object storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Pure cache instances where you'd merely like a warm cache after a planned restart; session stores where losing a few minutes is acceptable; the backup layer underneath AOF in hybrid setups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; You will lose everything written since the last snapshot on a crash or hard reboot; fork-based copy-on-write can transiently spike memory toward 2x on write-heavy instances; fork pauses grow with dataset size; the &lt;code&gt;save&lt;/code&gt; thresholds mean quiet periods can leave data unprotected for the full 15-minute window.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does AOF Persistence Work?
&lt;/h2&gt;

&lt;p&gt;AOF (append-only file) takes the opposite approach: instead of periodic snapshots, Valkey logs every write command to a file as it happens, and on restart replays the log to reconstruct the dataset (&lt;a href="https://valkey.io/docs/" rel="noopener noreferrer"&gt;Valkey docs&lt;/a&gt;). Durability now depends on one question: how often does the log actually reach the disk? That's the &lt;code&gt;appendfsync&lt;/code&gt; directive, and it's the single most consequential line in a queue server's config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/valkey/valkey.conf
&lt;/span&gt;
&lt;span class="n"&gt;appendonly&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;appendfilename&lt;/span&gt; &lt;span class="s2"&gt;"appendonly.aof"&lt;/span&gt;
&lt;span class="n"&gt;appenddirname&lt;/span&gt; &lt;span class="s2"&gt;"appendonlydir"&lt;/span&gt;

&lt;span class="c"&gt;# fsync policy: always | everysec | no
&lt;/span&gt;&lt;span class="n"&gt;appendfsync&lt;/span&gt; &lt;span class="n"&gt;everysec&lt;/span&gt;

&lt;span class="c"&gt;# Rewrite (compact) the AOF when it doubles in size, min 64mb
&lt;/span&gt;&lt;span class="n"&gt;auto&lt;/span&gt;-&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;rewrite&lt;/span&gt;-&lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;span class="n"&gt;auto&lt;/span&gt;-&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;rewrite&lt;/span&gt;-&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="n"&gt;mb&lt;/span&gt;

&lt;span class="c"&gt;# Don't block writes if a rewrite is fsyncing (trade tiny risk for latency)
&lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;-&lt;span class="n"&gt;appendfsync&lt;/span&gt;-&lt;span class="n"&gt;on&lt;/span&gt;-&lt;span class="n"&gt;rewrite&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three &lt;code&gt;appendfsync&lt;/code&gt; policies map to three honesty levels about data loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;always&lt;/code&gt;: fsync after every write. You can lose at most one command on a crash, but throughput drops hard because every &lt;code&gt;LPUSH&lt;/code&gt; waits on the disk. On commodity VPS storage this can cut write throughput by an order of magnitude. Reserve it for genuinely irreplaceable data where Valkey is the system of record.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;everysec&lt;/code&gt;: fsync once per second in the background. You lose at most about one second of writes on a hard crash. This is the default and the right answer for almost every Laravel queue. One second of jobs is a bounded, explainable loss; fifteen minutes is not.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no&lt;/code&gt;: let the kernel decide, typically flushing every 30 seconds. Barely better than RDB for durability. Skip it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AOF Rewrites and Restart Times
&lt;/h3&gt;

&lt;p&gt;An append-only log grows forever, so Valkey periodically compacts it. A rewrite forks a child (same copy-on-write caveats as RDB) that writes the minimal set of commands to rebuild the current dataset, while new writes buffer and get appended at the end. The &lt;code&gt;auto-aof-rewrite-*&lt;/code&gt; directives above trigger this automatically when the file doubles. Between rewrites, budget real disk for growth: a write-heavy queue instance can add hundreds of megabytes of AOF per day, and a full disk stops Valkey from accepting writes entirely. That failure mode deserves its own runbook, which we wrote up in &lt;a href="https://deploynix.io/blog/when-your-server-runs-out-of-disk-space-prevention-detection-and-recovery" rel="noopener noreferrer"&gt;our guide to disk-space prevention, detection, and recovery&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The other cost is restarts. Replaying a large command log is much slower than loading an RDB snapshot, and on big datasets a pure-AOF restart can take minutes while your workers throw connection refused. Hybrid mode, next, exists precisely to fix this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Bounded, tunable data loss (down to ~1 second with &lt;code&gt;everysec&lt;/code&gt;); the log is human-auditable with &lt;code&gt;valkey-check-aof&lt;/code&gt;; a partially written tail from a crash can usually be truncated and recovered rather than lost wholesale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Queue instances, Horizon metadata, and any dataset where Valkey is the only copy of the data; session stores on applications where mass logout is a real business problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Larger files and steady write I/O; rewrite forks carry the same copy-on-write memory spike as BGSAVE; slower restarts in pure-AOF form; &lt;code&gt;appendfsync always&lt;/code&gt; is usually overkill and can bottleneck queue throughput below what your workers need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Is Hybrid Persistence the Modern Default?
&lt;/h2&gt;

&lt;p&gt;Hybrid persistence gives you RDB's fast restarts and AOF's tight durability in one mode, and Valkey ships it enabled by default whenever AOF is on (&lt;a href="https://valkey.io/docs/" rel="noopener noreferrer"&gt;Valkey docs&lt;/a&gt;). The mechanism is simple: during each AOF rewrite, the child writes the dataset in RDB binary format as a preamble, and only commands received after the rewrite get appended as a plain-text log tail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/valkey/valkey.conf
&lt;/span&gt;
&lt;span class="n"&gt;appendonly&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;appendfsync&lt;/span&gt; &lt;span class="n"&gt;everysec&lt;/span&gt;
&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;use&lt;/span&gt;-&lt;span class="n"&gt;rdb&lt;/span&gt;-&lt;span class="n"&gt;preamble&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On restart, Valkey bulk-loads the compact RDB preamble, then replays only the short tail of recent commands. Restart times land close to pure RDB while data loss stays bounded by your &lt;code&gt;appendfsync&lt;/code&gt; policy. Since the preamble regenerates on every rewrite, file growth stays managed too.&lt;/p&gt;

&lt;p&gt;We treat this as the default answer for any instance holding data you can't regenerate. The honest question is no longer "RDB or AOF?" but "does this instance need durability at all?" If yes, hybrid with &lt;code&gt;everysec&lt;/code&gt;. If no, RDB alone or nothing. If you're migrating from Redis, note that &lt;code&gt;aof-use-rdb-preamble&lt;/code&gt; has defaulted to &lt;code&gt;yes&lt;/code&gt; since Redis 5, so many teams are already running hybrid without knowing it, which is one of the few pleasant surprises in this area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Near-RDB restart speed with AOF-grade durability; automatic compaction keeps disk usage predictable; it's the upstream default, so you're not fighting the tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Queue and Horizon instances, which is to say, the instance whose loss would have made this article's intro about you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Still fork-based rewrites, so the copy-on-write memory math still applies; the AOF file's mixed binary/text format means you can't casually &lt;code&gt;grep&lt;/code&gt; the preamble portion; requires AOF enabled, so pure-RDB-only setups need two directive changes, not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  RDB vs. AOF vs. Hybrid vs. No Persistence: The Comparison
&lt;/h2&gt;

&lt;p&gt;One table, four options, the trade-offs that actually matter on a production Laravel box:&lt;/p&gt;

&lt;p&gt;RDB only&lt;/p&gt;

&lt;p&gt;AOF only (&lt;code&gt;everysec&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Hybrid (RDB preamble + AOF)&lt;/p&gt;

&lt;p&gt;No persistence&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Max data loss (crash)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Up to full save interval (minutes)&lt;/p&gt;

&lt;p&gt;~1 second&lt;/p&gt;

&lt;p&gt;~1 second&lt;/p&gt;

&lt;p&gt;Everything&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restart speed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fast&lt;/p&gt;

&lt;p&gt;Slow (full log replay)&lt;/p&gt;

&lt;p&gt;Fast (bulk preamble + short tail)&lt;/p&gt;

&lt;p&gt;Instant (empty)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steady-state disk I/O&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bursty (snapshot only)&lt;/p&gt;

&lt;p&gt;Continuous appends&lt;/p&gt;

&lt;p&gt;Continuous appends&lt;/p&gt;

&lt;p&gt;None&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk footprint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Small, compact&lt;/p&gt;

&lt;p&gt;Large between rewrites&lt;/p&gt;

&lt;p&gt;Moderate, managed&lt;/p&gt;

&lt;p&gt;None&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fork memory spike&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes (BGSAVE)&lt;/p&gt;

&lt;p&gt;Yes (rewrite)&lt;/p&gt;

&lt;p&gt;Yes (rewrite)&lt;/p&gt;

&lt;p&gt;No&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup story&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Excellent (copy one file)&lt;/p&gt;

&lt;p&gt;Workable&lt;/p&gt;

&lt;p&gt;Good&lt;/p&gt;

&lt;p&gt;N/A&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fit for Laravel queues&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Risky&lt;/p&gt;

&lt;p&gt;Good&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommended&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Never&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fit for pure cache&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fine (warm restarts)&lt;/p&gt;

&lt;p&gt;Wasteful&lt;/p&gt;

&lt;p&gt;Wasteful&lt;/p&gt;

&lt;p&gt;Fine&lt;/p&gt;

&lt;p&gt;Read the first row twice. It's the row the 3 a.m. reboot cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Persistence Mode Fits Each Laravel Workload?
&lt;/h2&gt;

&lt;p&gt;The clean answer is per-workload, which means the clean architecture is per-instance. Here's the recommendation table we apply to our own infrastructure:&lt;/p&gt;

&lt;p&gt;Laravel workload&lt;/p&gt;

&lt;p&gt;Persistence&lt;/p&gt;

&lt;p&gt;&lt;code&gt;appendfsync&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxmemory-policy&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Cache (&lt;code&gt;CACHE_STORE=redis&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;None, or RDB only&lt;/p&gt;

&lt;p&gt;n/a&lt;/p&gt;

&lt;p&gt;&lt;code&gt;allkeys-lru&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Cold cache is a performance blip, not data loss&lt;/p&gt;

&lt;p&gt;Sessions (&lt;code&gt;SESSION_DRIVER=redis&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Hybrid&lt;/p&gt;

&lt;p&gt;&lt;code&gt;everysec&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;noeviction&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Evicted sessions = random logouts under memory pressure&lt;/p&gt;

&lt;p&gt;Queues (&lt;code&gt;QUEUE_CONNECTION=redis&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;everysec&lt;/code&gt; (or &lt;code&gt;always&lt;/code&gt; for payments-grade jobs)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;noeviction&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The one you cannot get wrong&lt;/p&gt;

&lt;p&gt;Horizon metadata&lt;/p&gt;

&lt;p&gt;Hybrid (shares queue instance)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;everysec&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;noeviction&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Inherits queue settings&lt;/p&gt;

&lt;p&gt;Broadcasting (Reverb pub/sub)&lt;/p&gt;

&lt;p&gt;None&lt;/p&gt;

&lt;p&gt;n/a&lt;/p&gt;

&lt;p&gt;n/a&lt;/p&gt;

&lt;p&gt;Pub/sub messages are never stored; persistence is irrelevant&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate Instances Beat SELECT n
&lt;/h3&gt;

&lt;p&gt;Laravel's default &lt;code&gt;config/database.php&lt;/code&gt; nudges you toward one Valkey server with logical databases: &lt;code&gt;default&lt;/code&gt; on database 0, &lt;code&gt;cache&lt;/code&gt; on database 1, selected via &lt;code&gt;SELECT n&lt;/code&gt;. That works, but it forces one persistence policy and one eviction policy onto every workload, because both &lt;code&gt;appendonly&lt;/code&gt; and &lt;code&gt;maxmemory-policy&lt;/code&gt; are server-wide, not per-database. You can't snapshot database 1 casually while fsyncing database 0 religiously. In our experience, logical databases are how teams end up with cache-grade durability protecting their queues without realizing it.&lt;/p&gt;

&lt;p&gt;Running two &lt;code&gt;valkey-server&lt;/code&gt; processes on different ports (or a dedicated cache host) costs a few megabytes of overhead and buys you independent persistence, independent eviction, independent &lt;code&gt;maxmemory&lt;/code&gt;, and independent restarts. Restarting a misbehaving cache instance no longer risks your queue backlog. We covered wiring multiple connections into Laravel's queue config in our &lt;a href="https://deploynix.io/blog/laravel-queues-deep-dive-connections-workers-and-retry-strategies-on-deploynix" rel="noopener noreferrer"&gt;queues deep dive on connections, workers, and retry strategies&lt;/a&gt;, and the pattern is a few extra lines in &lt;code&gt;config/database.php&lt;/code&gt; pointing each connection at its own port.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Eviction Policy Trap
&lt;/h3&gt;

&lt;p&gt;Persistence protects you from restarts. Eviction policy protects you from memory pressure, and it can destroy queue data while the server stays up. With &lt;code&gt;maxmemory&lt;/code&gt; set and &lt;code&gt;allkeys-lru&lt;/code&gt; active, Valkey evicts the least-recently-used keys when memory fills. On a cache, that's the whole point. On a queue instance, "least recently used" can be the list holding your job backlog, and Valkey will delete it silently to make room for new writes. No crash, no restart, jobs gone, persistence intact and faithfully persisting the deletion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cache instance
&lt;/span&gt;&lt;span class="n"&gt;maxmemory&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;&lt;span class="n"&gt;mb&lt;/span&gt;
&lt;span class="n"&gt;maxmemory&lt;/span&gt;-&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;allkeys&lt;/span&gt;-&lt;span class="n"&gt;lru&lt;/span&gt;

&lt;span class="c"&gt;# Queue instance: refuse writes rather than evict jobs
&lt;/span&gt;&lt;span class="n"&gt;maxmemory&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;gb&lt;/span&gt;
&lt;span class="n"&gt;maxmemory&lt;/span&gt;-&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;noeviction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;noeviction&lt;/code&gt;, a full queue instance rejects new writes with an error your application can actually see and alert on. A visible failure beats silent data loss every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Persistence Works on Deploynix Servers
&lt;/h2&gt;

&lt;p&gt;When Deploynix provisions servers, on DigitalOcean, Vultr, Linode, Hetzner, AWS, or a custom box, Valkey shows up in two places, and the separate-instances advice above maps directly onto them. Every app server includes a local Valkey instance alongside Nginx, PHP 8.4, and Supervisor, the stack we described in &lt;a href="https://deploynix.io/blog/setting-up-a-production-ready-laravel-stack-nginx-php-84-mysql-valkey-supervisor" rel="noopener noreferrer"&gt;setting up a production-ready Laravel stack&lt;/a&gt;. And the dedicated cache server type runs Valkey as its sole job, TLS-enabled out of the box, so cache traffic between your app servers and the cache host is encrypted in transit without extra certificate wrangling.&lt;/p&gt;

&lt;p&gt;That two-tier layout gives you the split for free: keep the pure cache on the dedicated cache server with &lt;code&gt;allkeys-lru&lt;/code&gt; and light or no persistence, and keep queues on the app server's local instance (or a second dedicated instance) with hybrid persistence and &lt;code&gt;noeviction&lt;/code&gt;. Database servers (MySQL, MariaDB, PostgreSQL) stay on their own boxes, so a fork-related memory spike on the Valkey side never competes with InnoDB's buffer pool.&lt;/p&gt;

&lt;p&gt;On a provisioned server, the config lives at &lt;code&gt;/etc/valkey/valkey.conf&lt;/code&gt; and the service runs under systemd as &lt;code&gt;valkey&lt;/code&gt;. For applying changes, we'd suggest this order of preference. First, runtime changes with &lt;code&gt;CONFIG SET&lt;/code&gt;, which take effect instantly with zero downtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;valkey-cli CONFIG SET appendonly &lt;span class="nb"&gt;yes
&lt;/span&gt;valkey-cli CONFIG SET appendfsync everysec
valkey-cli CONFIG SET maxmemory-policy noeviction
valkey-cli CONFIG SET save &lt;span class="s2"&gt;"900 1 300 10 60 10000"&lt;/span&gt;

&lt;span class="c"&gt;# Persist the runtime state back into valkey.conf&lt;/span&gt;
valkey-cli CONFIG REWRITE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CONFIG REWRITE&lt;/code&gt; is the step people forget: without it, your carefully applied runtime settings evaporate on the next restart, which is a particularly ironic way to lose your persistence config. If you edit &lt;code&gt;valkey.conf&lt;/code&gt; by hand instead, restart deliberately, and only after confirming a fresh snapshot or AOF sync exists, because restarting a non-persistent instance is exactly the reboot scenario from the intro, self-inflicted. Deploynix's server monitoring also raises memory and disk alerts, which is where fork spikes and AOF growth surface first in practice; a memory alert that coincides with &lt;code&gt;rdb_bgsave_in_progress:1&lt;/code&gt; tells you the copy-on-write math is getting tight. Queue workers themselves are managed from the UI, so once the underlying store is durable, restarting workers after a deploy doesn't touch the data layer at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Monitor and Test Persistence in Production?
&lt;/h2&gt;

&lt;p&gt;A persistence config you've never tested is a hypothesis, not a safety net. Two habits close the gap: watch the right metrics continuously, and rehearse the restart before reality schedules one for you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;INFO persistence&lt;/code&gt; is the primary instrument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;valkey-cli INFO persistence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Persistence
loading:0
rdb_changes_since_last_save:1204
rdb_bgsave_in_progress:0
rdb_last_save_time:1754121600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok
latest_fork_usec:41250
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four fields earn alerts. &lt;code&gt;rdb_last_bgsave_status&lt;/code&gt; and &lt;code&gt;aof_last_write_status&lt;/code&gt; must be &lt;code&gt;ok&lt;/code&gt;; anything else means snapshots or fsyncs are failing, usually because the disk is full, and with &lt;code&gt;stop-writes-on-bgsave-error yes&lt;/code&gt; your application is about to find out loudly. &lt;code&gt;rdb_changes_since_last_save&lt;/code&gt; tells you exactly how many writes are currently unprotected by RDB, useful for judging whether your &lt;code&gt;save&lt;/code&gt; thresholds match reality. And &lt;code&gt;latest_fork_usec&lt;/code&gt; is your fork-cost gauge: it reports the last fork's duration in microseconds, and since the entire server stalls during the fork itself, a value creeping past ~500,000 (half a second) on a growing dataset is an early warning that snapshot pauses will soon be user-visible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LASTSAVE&lt;/code&gt; gives you a quick freshness check, and pairing it with &lt;code&gt;BGSAVE&lt;/code&gt; makes a tidy pre-maintenance ritual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Unix timestamp of the last successful RDB save&lt;/span&gt;
valkey-cli LASTSAVE

&lt;span class="c"&gt;# Force a snapshot now, then confirm the timestamp advanced&lt;/span&gt;
valkey-cli BGSAVE
valkey-cli LASTSAVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then there's the test almost nobody runs: the controlled durability drill. On a staging box, or during a quiet window with a maintenance page up, push a handful of marker jobs onto a queue, restart the service, and count them afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;valkey-cli LPUSH queues:durability-test job1 job2 job3
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart valkey
valkey-cli LLEN queues:durability-test   &lt;span class="c"&gt;# expect: 3&lt;/span&gt;
valkey-cli DEL queues:durability-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the count comes back 0, your persistence config is theater, and you've learned it for the price of three fake jobs instead of four thousand real ones. While you're at it, verify disk headroom for AOF growth: &lt;code&gt;ls -lh /var/lib/valkey/appendonlydir&lt;/code&gt; weekly, or let disk alerts do it for you. One more production note: if your Valkey instance is reachable over the network rather than localhost, TLS isn't optional, since queue payloads routinely contain user data; terminate it in Valkey itself (&lt;code&gt;tls-port&lt;/code&gt;, &lt;code&gt;tls-cert-file&lt;/code&gt;, &lt;code&gt;tls-key-file&lt;/code&gt;) rather than bolting on a proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes If You're Migrating From Redis?
&lt;/h2&gt;

&lt;p&gt;Nothing, and that's the point. Valkey is wire-compatible with Redis on both RESP2 and RESP3, and every directive in this article, &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;appendonly&lt;/code&gt;, &lt;code&gt;appendfsync&lt;/code&gt;, &lt;code&gt;aof-use-rdb-preamble&lt;/code&gt;, &lt;code&gt;maxmemory-policy&lt;/code&gt;, carries over character for character (&lt;a href="https://dev.to/synsun/redis-vs-valkey-in-2026-what-the-license-fork-actually-changed-1kni"&gt;dev.to fork retrospective&lt;/a&gt;). Your existing &lt;code&gt;redis.conf&lt;/code&gt; persistence block can be pasted into &lt;code&gt;valkey.conf&lt;/code&gt; unmodified, and Valkey will even load an existing &lt;code&gt;dump.rdb&lt;/code&gt; or AOF directory produced by Redis 7.x, which makes the migration itself a stop-copy-start operation rather than a data export.&lt;/p&gt;

&lt;p&gt;Laravel doesn't notice either: phpredis and Predis speak to Valkey exactly as they spoke to Redis, so &lt;code&gt;config/database.php&lt;/code&gt; needs a host change at most. With Redis 7.2 past its February 2026 end-of-life and Valkey 9.1 posting ~2.1M requests/second with another ~10% memory trim (&lt;a href="https://danubedata.ro/blog/redis-vs-valkey-vs-dragonfly-2026" rel="noopener noreferrer"&gt;DanubeData&lt;/a&gt;), the migration question has mostly answered itself; the compatibility details, RESP protocol versions, data formats, module edge cases, are in our &lt;a href="https://deploynix.io/blog/valkey-vs-redis-api-protocol-data-format-compatibility-the-technical-deep-dive" rel="noopener noreferrer"&gt;technical deep dive on Valkey vs. Redis compatibility&lt;/a&gt; if you want the full audit before switching.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I just enable both RDB and AOF instead of hybrid mode?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's a legitimate setup: AOF drives recovery (Valkey prefers it on restart because it's more complete), while standalone RDB snapshots give you clean, compact files to ship off-server as backups. Hybrid mode with &lt;code&gt;aof-use-rdb-preamble yes&lt;/code&gt; additionally embeds the RDB format inside the AOF for fast restarts, and the three directives coexist happily. Most durable production instances end up running all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much data do I actually lose with appendfsync everysec?
&lt;/h3&gt;

&lt;p&gt;At most about one second of acknowledged writes, and only on a hard crash or power loss, not on a clean &lt;code&gt;systemctl restart&lt;/code&gt;, which fsyncs on shutdown. For a queue pushing 50 jobs per second, that's a worst case of ~50 jobs, versus thousands under RDB's multi-minute windows. If even one second is unacceptable, use &lt;code&gt;appendfsync always&lt;/code&gt; for that instance and accept the throughput cost, or reconsider whether that workload belongs in a database-backed queue instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need persistence on a cache-only Valkey instance?
&lt;/h3&gt;

&lt;p&gt;No, and skipping it is often the better call: you save the fork spikes, the disk I/O, and the restart-time snapshot loading. The one argument for RDB on a cache is warm restarts, since reloading a recent snapshot avoids the thundering-herd effect of a fully cold cache after planned maintenance. If your database shrugs off a cold cache, run the cache instance with no persistence and a clear conscience.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is noeviction safe for queues? Won't Valkey just fall over when memory fills?
&lt;/h3&gt;

&lt;p&gt;It refuses writes with an explicit error, which your application sees as a failed dispatch, something you can catch, retry, and alert on. That's the failure mode you want, because the alternative under &lt;code&gt;allkeys-lru&lt;/code&gt; is Valkey silently deleting your backlog to make room. Pair &lt;code&gt;noeviction&lt;/code&gt; with a &lt;code&gt;maxmemory&lt;/code&gt; set well below server RAM (leaving fork headroom) and a memory alert, and a filling queue instance becomes a paged engineer instead of a postmortem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does any of this apply to Laravel Reverb broadcasting?
&lt;/h3&gt;

&lt;p&gt;Mostly no. Broadcasting rides on pub/sub, and pub/sub messages pass through Valkey without ever being stored, so no persistence mode retains them; a subscriber that's offline misses the message regardless. The only broadcast-adjacent data worth persisting is whatever your app stores about presence or state in regular keys, which follows the normal cache-or-durable decision like everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start This Week
&lt;/h2&gt;

&lt;p&gt;The whole decision compresses into three moves. Split cache from queues so each gets its own policy: separate instances, not &lt;code&gt;SELECT n&lt;/code&gt;. Give the queue instance hybrid persistence (&lt;code&gt;appendonly yes&lt;/code&gt;, &lt;code&gt;appendfsync everysec&lt;/code&gt;, &lt;code&gt;aof-use-rdb-preamble yes&lt;/code&gt;) with &lt;code&gt;noeviction&lt;/code&gt;, and let the cache instance stay fast and disposable with &lt;code&gt;allkeys-lru&lt;/code&gt;. Then prove it: run the marker-job restart drill and watch &lt;code&gt;INFO persistence&lt;/code&gt; come back clean.&lt;/p&gt;

&lt;p&gt;Your next step is a five-minute audit that costs nothing: run &lt;code&gt;valkey-cli CONFIG GET appendonly&lt;/code&gt; and &lt;code&gt;valkey-cli CONFIG GET maxmemory-policy&lt;/code&gt; against whatever instance backs your production queues, today. If the answers are &lt;code&gt;no&lt;/code&gt; and &lt;code&gt;allkeys-lru&lt;/code&gt;, you're one host reboot away from the story this post opened with, and now you know exactly which four lines of &lt;code&gt;valkey.conf&lt;/code&gt; fix it.&lt;/p&gt;

</description>
      <category>valkey</category>
      <category>caching</category>
      <category>laravel</category>
      <category>queues</category>
    </item>
    <item>
      <title>SOC 2 for a Laravel SaaS: What Infrastructure Evidence You'll Actually Need</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:03:05 +0000</pubDate>
      <link>https://dev.to/deploynix/soc-2-for-a-laravel-saas-what-infrastructure-evidence-youll-actually-need-1c8k</link>
      <guid>https://dev.to/deploynix/soc-2-for-a-laravel-saas-what-infrastructure-evidence-youll-actually-need-1c8k</guid>
      <description>&lt;p&gt;The deal was almost done. A mid-market company loved your Laravel product, the champion had budget approval, and the contract was sitting in legal. Then procurement sent over a security questionnaire with 240 questions, and question three asked: "Please attach your most recent SOC 2 Type II report." You don't have one. The deal doesn't die, but it stalls, and every week it sits in review is a week your competitor with a report in hand looks safer to buy from.&lt;/p&gt;

&lt;p&gt;If you're a small Laravel team, this is almost always how SOC 2 enters your life. Not through regulation, not through a security incident, but through a sales blocker. And the way it's usually explained, by auditors, for auditors, makes it sound far more mystical than it is. Strip away the vocabulary and SOC 2 is a structured way of answering one question: can you prove that you actually operate the security controls you claim to operate?&lt;/p&gt;

&lt;p&gt;The good news is that a well-run VPS-based Laravel stack already produces most of the evidence an auditor wants. Key-only SSH, firewall rules, git history, deploy logs, automated backups, monitoring alerts: these aren't compliance artifacts bolted on later, they're the exhaust of running production responsibly. The hard part isn't doing the work. It's capturing proof that you did it, continuously, for months.&lt;/p&gt;

&lt;p&gt;This post walks through SOC 2 the way we'd want it explained to us as engineers: when you actually need it, what evidence maps to what criteria, where teams get caught out (restore drills, offboarding), and what a realistic timeline looks like for a team of two to ten people.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Key Takeaways- SOC 2 is an AICPA framework audited by licensed CPA firms; only the Security criterion is mandatory in scope. - Type I assesses control design at a point in time; Type II proves operation over a 3-12 month window. - Auditors want evidence, not claims: restore logs beat backup configs, access reviews beat access policies. - A hardened VPS Laravel stack already generates most required evidence if you capture it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Is SOC 2, and When Does a Laravel SaaS Actually Need One?
&lt;/h2&gt;

&lt;p&gt;SOC 2 is an attestation framework created by the &lt;a href="https://www.aicpa-cima.com" rel="noopener noreferrer"&gt;AICPA&lt;/a&gt;, the American Institute of Certified Public Accountants. It is not a certification you self-declare and not a government regulation. A licensed CPA firm examines your controls and issues a report stating whether those controls are designed properly and, for Type II, whether they operated effectively over an observation period.&lt;/p&gt;

&lt;p&gt;The framework is built on five Trust Services Criteria: Security, Availability, Confidentiality, Processing Integrity, and Privacy. Only Security is mandatory. The other four are optional scope you add based on what your customers care about. Most small SaaS companies start with Security alone or Security plus Availability, because those are the two that enterprise procurement teams actually ask about. Adding Privacy or Processing Integrity expands the audit surface considerably, and unless your buyers demand it, there's little reason to volunteer for it in year one.&lt;/p&gt;

&lt;p&gt;So when do you need it? Honestly: when sales tells you that you do. SOC 2 is an enterprise sales gate, not a legal requirement. If you sell to individual developers and small agencies, nobody will ever ask. The moment your average contract value climbs into the range where procurement departments get involved, the questionnaires start, and "we take security seriously" stops being an acceptable answer. If you're seeing security questionnaires on more than one deal per quarter, or a specific large deal is blocked on it, that's your signal. Before that point, your time is better spent on the underlying security work itself, which is why we'd point any Laravel team at &lt;a href="https://deploynix.io/blog/a-laravel-developers-production-security-checklist-2026-edition" rel="noopener noreferrer"&gt;a production security checklist&lt;/a&gt; long before an audit is on the calendar. Everything you fix now becomes evidence later.&lt;/p&gt;

&lt;p&gt;One more thing worth internalizing early: SOC 2 doesn't prescribe specific technology. There is no rule that says "use AWS" or "use this framework." The auditor evaluates whether your controls meet the criteria, not whether your stack matches some enterprise template. A Laravel monolith on a well-managed VPS can pass the same audit as a Kubernetes fleet, and often with a cleaner story, because there's less surface to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type I or Type II: Which Report Should You Get First?
&lt;/h2&gt;

&lt;p&gt;The two report types answer different questions, and the difference matters more than most first-timers realize.&lt;/p&gt;

&lt;p&gt;Type I&lt;/p&gt;

&lt;p&gt;Type II&lt;/p&gt;

&lt;p&gt;What it assesses&lt;/p&gt;

&lt;p&gt;Control design at a single point in time&lt;/p&gt;

&lt;p&gt;Control operating effectiveness over a period&lt;/p&gt;

&lt;p&gt;Observation window&lt;/p&gt;

&lt;p&gt;None (snapshot date)&lt;/p&gt;

&lt;p&gt;Typically 3-12 months&lt;/p&gt;

&lt;p&gt;Auditor asks&lt;/p&gt;

&lt;p&gt;"Do these controls exist and make sense?"&lt;/p&gt;

&lt;p&gt;"Did these controls actually run, every time, all period?"&lt;/p&gt;

&lt;p&gt;Evidence burden&lt;/p&gt;

&lt;p&gt;Policies, configurations, one-time screenshots&lt;/p&gt;

&lt;p&gt;Continuous logs, tickets, reviews, drill records&lt;/p&gt;

&lt;p&gt;Time to obtain&lt;/p&gt;

&lt;p&gt;Weeks after readiness&lt;/p&gt;

&lt;p&gt;Observation window plus audit time&lt;/p&gt;

&lt;p&gt;Weight with enterprise buyers&lt;/p&gt;

&lt;p&gt;Moderate; often accepted as a bridge&lt;/p&gt;

&lt;p&gt;Strong; the default procurement ask&lt;/p&gt;

&lt;p&gt;Typical use&lt;/p&gt;

&lt;p&gt;First-year milestone while Type II window runs&lt;/p&gt;

&lt;p&gt;The report you renew annually&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type I&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Fast to obtain once your controls are designed and documented. Gives sales something real to attach to a questionnaire within weeks instead of months. Forces you to write down policies and close obvious gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams with an enterprise deal stalling right now who can't wait out an observation window, or teams using it as a forcing function to get their house in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Sophisticated buyers know a Type I only proves your controls existed on one day. Some procurement teams accept it as a bridge with a committed Type II date; others won't. Budget-wise you're paying for two audits in your first year if you do both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type II&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; The report buyers actually want. Proves your controls operated over a real window, which is a fundamentally stronger claim. Renewing it annually becomes routine once evidence collection is automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that can start their observation window now and close enterprise deals in six to twelve months, or teams whose buyers have explicitly said Type I won't cut it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; The observation window is a minimum wait, not a formality. A control that lapsed for three weeks mid-window becomes an exception in your report. You need evidence discipline for the entire period, which is exactly where compliance automation platforms earn their fee.&lt;/p&gt;

&lt;p&gt;The pragmatic path we see most small teams take: start the readiness work immediately, open a Type II observation window as soon as controls are stable, and optionally grab a Type I along the way if a specific deal demands paper sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Infrastructure Evidence Do Auditors Actually Ask For?
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody explains well. Auditors don't inspect your servers. They request evidence: exports, logs, screenshots, tickets, and records that demonstrate each control operated. For a VPS-based Laravel stack, the mapping looks like this.&lt;/p&gt;

&lt;p&gt;Trust Services Criterion&lt;/p&gt;

&lt;p&gt;In scope?&lt;/p&gt;

&lt;p&gt;Concrete infrastructure evidence from a Laravel VPS stack&lt;/p&gt;

&lt;p&gt;Security&lt;/p&gt;

&lt;p&gt;Mandatory&lt;/p&gt;

&lt;p&gt;SSH key inventories, firewall rules, fail2ban logs, patching records, access review sign-offs, deploy logs, encrypted secrets configuration&lt;/p&gt;

&lt;p&gt;Availability&lt;/p&gt;

&lt;p&gt;Common add-on&lt;/p&gt;

&lt;p&gt;Uptime monitoring history, alert logs, backup schedules, restore drill records, incident post-mortems, rollback records&lt;/p&gt;

&lt;p&gt;Confidentiality&lt;/p&gt;

&lt;p&gt;Common add-on&lt;/p&gt;

&lt;p&gt;Encryption-at-rest evidence, TLS configuration, access scoping by role, data classification notes&lt;/p&gt;

&lt;p&gt;Processing Integrity&lt;/p&gt;

&lt;p&gt;Rare for small SaaS&lt;/p&gt;

&lt;p&gt;Job queue monitoring, data validation controls, reconciliation records&lt;/p&gt;

&lt;p&gt;Privacy&lt;/p&gt;

&lt;p&gt;Rare; overlaps GDPR&lt;/p&gt;

&lt;p&gt;Data retention schedules, deletion records, subprocessor list, server location documentation&lt;/p&gt;

&lt;p&gt;If you've already done GDPR work, some of this will feel familiar; our post on &lt;a href="https://deploynix.io/blog/gdpr-for-laravel-developers-data-storage-deletion-and-server-location-on-deploynix" rel="noopener noreferrer"&gt;GDPR for Laravel developers&lt;/a&gt; covers the data-location and deletion side, and that documentation is reusable if you ever add Privacy to scope. Now let's go domain by domain through what the Security and Availability criteria actually demand from your infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access Control: Who Can Touch Production, and How Do You Know?
&lt;/h3&gt;

&lt;p&gt;This is the first thing every auditor probes, and the first place small teams fail. The questions are simple. Who has SSH access to production? How is it granted? How is it revoked when someone leaves? Can two people be told apart in your logs?&lt;/p&gt;

&lt;p&gt;The engineering answers are ones you should already have: key-only SSH with password authentication disabled, no shared root credentials, one key per human, and a defined offboarding step that removes keys and platform access on the person's last day. The evidence answers are the exports that prove it. A dump of &lt;code&gt;authorized_keys&lt;/code&gt; entries mapped to named people. A record showing the departed contractor's key was removed within your stated window. Quarterly access review sign-offs where someone actually looked at the list and confirmed everyone on it still belongs there.&lt;/p&gt;

&lt;p&gt;Shared accounts are the classic finding here. If three engineers all log in as &lt;code&gt;deploy&lt;/code&gt; with the same key, your logs can't attribute actions to individuals, and the auditor will flag it. Individual keys plus a platform layer with per-user accounts and roles solves this cleanly. Offboarding is the second classic finding: teams that have never once removed an ex-employee's access, because nobody ever left before, discover mid-audit that they have no process and no record.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change Management: Can You Show How Code Reaches Production?
&lt;/h3&gt;

&lt;p&gt;Auditors want to see that changes to production are authorized, reviewed, and traceable. If you're using git with pull requests, you already run a change management system; you just haven't been calling it that.&lt;/p&gt;

&lt;p&gt;Your git history is the change record. PR reviews are the authorization step. Deploy logs, with timestamps, the deploying user, and the commit deployed, tie the change record to production. Rollback capability is your answer to "what happens when a change goes wrong," and a log showing you actually rolled back a bad deploy is better evidence than any policy paragraph about rollback procedures.&lt;/p&gt;

&lt;p&gt;What trips teams up: direct pushes to main with no review, hotfixes applied by SSHing in and editing files on the server (which bypasses the entire change record), and deploys with no log of who triggered them. The fix isn't process theater. It's making the pipeline the only path to production and letting the pipeline's own logs be your evidence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A surprising amount of change-management evidence is one command away&lt;/span&gt;
git log &lt;span class="nt"&gt;--merges&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%h %an %ad %s"&lt;/span&gt; &lt;span class="nt"&gt;--date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;short

&lt;span class="c"&gt;# Show that branch protection forces review (GitHub CLI)&lt;/span&gt;
gh api repos/OWNER/REPO/branches/main/protection &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.required_pull_request_reviews'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Vulnerability Management: How Do Patches Actually Happen?
&lt;/h3&gt;

&lt;p&gt;The control here is a patching routine that runs whether or not anyone remembers it. On Ubuntu, that means unattended security upgrades, and the evidence is the configuration plus the logs showing it ran.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Confirm unattended upgrades are enabled&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/apt/apt.conf.d/20auto-upgrades
&lt;span class="c"&gt;# APT::Periodic::Update-Package-Lists "1";&lt;/span&gt;
&lt;span class="c"&gt;# APT::Periodic::Unattended-Upgrade "1";&lt;/span&gt;

&lt;span class="c"&gt;# Evidence that it has actually been running&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Packages that will be upgraded"&lt;/span&gt; /var/log/unattended-upgrades/unattended-upgrades.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the application side, &lt;code&gt;composer audit&lt;/code&gt; checks your dependency tree against known advisories, and running it in CI on every build turns it from a good habit into a logged, recurring control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer audit &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; audit-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the outputs. A folder of dated audit results showing you scanned weekly and remediated findings is exactly the kind of evidence that makes a Type II observation window painless. Auditors also like to see that findings go somewhere: a ticket that says "bumped guzzlehttp/psr7 for advisory X, deployed on this date" closes the loop from detection to fix to production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backup and Recovery: Auditors Want Restore Evidence, Not Backup Evidence
&lt;/h3&gt;

&lt;p&gt;Read that heading twice, because this is the single most common gap we see. Teams show auditors a backup schedule and a bucket full of dumps and consider the control satisfied. The auditor's next question: "Show me the last time you restored one."&lt;/p&gt;

&lt;p&gt;A backup you've never restored is a hypothesis. The control auditors actually care about is recoverability, and the evidence is a restore drill record: a dated log showing someone pulled a backup, restored it to a scratch database, verified row counts or ran the test suite against it, and recorded how long it took. Do this quarterly, write down the date, duration, and outcome each time, and you've turned twenty minutes of work into some of the strongest Availability evidence in your report.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A minimal quarterly restore drill, logged&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://backups/db/latest.sql.gz ./drill/ &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; &lt;span class="nv"&gt;$S3_ENDPOINT&lt;/span&gt;
&lt;span class="nb"&gt;gunzip &lt;/span&gt;drill/latest.sql.gz
mysql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 restore_drill &amp;lt; drill/latest.sql
mysql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 restore_drill &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT COUNT(*) FROM users; SELECT MAX(created_at) FROM orders;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; restore drill OK, 14m22s, verified users+orders tables"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; drills.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retention matters too: state a retention policy (say, 30 daily backups) and be able to show the bucket actually reflects it. If you want the full setup, our guide on &lt;a href="https://deploynix.io/blog/automated-database-backups-set-it-forget-it-sleep-well" rel="noopener noreferrer"&gt;automated database backups&lt;/a&gt; covers schedules, retention, and off-server storage; the drill habit is what upgrades that setup from good practice to audit evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring and Incident Response: What Happens When Things Break?
&lt;/h3&gt;

&lt;p&gt;The Availability criterion wants proof that you detect problems and respond to them in a structured way. Three artifacts cover most of it: monitoring with alert history, a written incident response playbook, and post-mortems for incidents that actually happened.&lt;/p&gt;

&lt;p&gt;Alert history is the underrated one. A log of every alert fired over the observation window, with timestamps showing when it fired and when it resolved, demonstrates that your monitoring is real and that someone responds. A playbook doesn't need to be long; ours fits the shape we described in &lt;a href="https://deploynix.io/blog/your-server-is-down-a-step-by-step-incident-response-playbook-for-deploynix-users" rel="noopener noreferrer"&gt;the incident response playbook for server outages&lt;/a&gt;: detect, triage, communicate, fix, review. What matters to an auditor is that it exists, names roles, and that your post-mortems reference it.&lt;/p&gt;

&lt;p&gt;And here's a counterintuitive tip from the trenches: incidents help your audit. A team with two documented incidents, each with a post-mortem and a follow-up fix, tells a stronger story than a team claiming a spotless record with no evidence their process has ever been exercised. Don't hide your outages from the auditor. Show them the paper trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Encryption: In Transit, at Rest, and in Your Secrets
&lt;/h3&gt;

&lt;p&gt;The expectations here are unambiguous. TLS on every public endpoint, with certificates that renew automatically. Secrets not sitting in plaintext in a repo or a shared doc. Backups encrypted before they land in object storage. Provider API tokens and SSH private keys encrypted at rest wherever they're stored.&lt;/p&gt;

&lt;p&gt;For a Laravel app, the secrets story deserves specific attention because &lt;code&gt;.env&lt;/code&gt; files are the soft underbelly of most stacks. Laravel's encrypted environment files (&lt;code&gt;php artisan env:encrypt&lt;/code&gt;) and a defined process for who can read production secrets go a long way; we wrote up the full approach in &lt;a href="https://deploynix.io/blog/secrets-management-for-laravel-env-encrypted-config-and-deploynix" rel="noopener noreferrer"&gt;secrets management for Laravel&lt;/a&gt;. Evidence-wise, the auditor wants your TLS configuration (an SSL Labs result or certificate inventory works), your encryption-at-rest description for the database and backups, and your answer to "who can see production credentials, and how is that access logged?"&lt;/p&gt;

&lt;p&gt;The firewall and intrusion-prevention layer rounds out the Security picture, and the evidence is literally command output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw status verbose
&lt;span class="c"&gt;# Status: active&lt;/span&gt;
&lt;span class="c"&gt;# Default: deny (incoming), allow (outgoing)&lt;/span&gt;
&lt;span class="c"&gt;# 22/tcp  ALLOW IN  Anywhere&lt;/span&gt;
&lt;span class="c"&gt;# 80,443/tcp  ALLOW IN  Anywhere&lt;/span&gt;

fail2ban-client status sshd
&lt;span class="c"&gt;# |- Currently banned: 3&lt;/span&gt;
&lt;span class="c"&gt;# `- Total banned: 412&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Total banned&lt;/code&gt; counter, captured monthly, is a tidy little proof that the control isn't just configured but actively working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gap Between "We Do This" and "We Can Prove We Do This"
&lt;/h2&gt;

&lt;p&gt;Every domain above has the same failure mode. The engineering is done, the control operates, and yet the audit is painful, because nothing was captured. Closing that gap is mostly about building three habits before your observation window opens, not after.&lt;/p&gt;

&lt;p&gt;First, prefer pulled evidence over posed evidence. A screenshot of a firewall config proves the config existed the moment you took the screenshot. A log exported from an API proves the control operated over time, can't be quietly staged, and doesn't require a human to remember anything. Wherever your infrastructure can emit evidence programmatically, wire that up; save screenshots for the handful of things that have no export. This is also the core value proposition of compliance automation platforms like Vanta, Drata, and Secureframe: they connect to your systems and collect evidence continuously so the observation window documents itself.&lt;/p&gt;

&lt;p&gt;Second, run quarterly access reviews and write them down. Fifteen minutes, four times a year: list everyone with production access, SSH keys, database credentials, and admin roles in your platform tooling; confirm each person still needs it; remove what's stale; record who reviewed and when. This single ritual satisfies a surprising number of audit requests, and it's the ritual most teams skip because "we're only four people, we know who has access." The auditor doesn't know that, and in a year you won't remember either.&lt;/p&gt;

&lt;p&gt;Third, keep policies short and true. You will need written policies: information security, access control, change management, incident response, business continuity. The temptation is to download a 40-page template and adopt it wholesale. Resist it. Auditors test whether you follow your policies, so every aspirational sentence you didn't need becomes a control you can fail. A two-page policy that accurately describes what your team actually does will serve you better than an impressive binder describing a company you aren't. Write down reality, then improve reality, then update the document.&lt;/p&gt;

&lt;p&gt;If you want a structured way to find the gaps before an auditor does, a self-run security audit of your own servers is the closest thing to a dress rehearsal: every item you verify is an item you now know how to evidence, and every item you can't verify just told you where your first exception would come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Deploynix Features Double as Audit Evidence
&lt;/h2&gt;

&lt;p&gt;We built Deploynix as a provisioning and deployment platform, not a compliance product. But somewhere around the third customer who asked us for evidence exports, we realized that a platform which manages your servers is, almost by accident, an evidence collection system. Here's the honest mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access control.&lt;/strong&gt; Deploynix teams have organization roles, so "who can deploy to production" is a defined, reviewable list rather than tribal knowledge, and the quarterly access review becomes reading one screen. Servers are provisioned with key-only SSH from day one, so there's no password-auth finding waiting to happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail.&lt;/strong&gt; Every server action taken through the platform, provisioning, deploys, config changes, is recorded in server logs with the acting user and timestamp. That's the attributable, per-person action history the access control and change management criteria both lean on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change management.&lt;/strong&gt; Deploys are logged with commit, user, and time, which turns each release into a change record that reconciles against your git history. Zero-downtime deploys with rollback give you the "what happens when a change is bad" answer, and a rollback that actually happened is sitting right there in the deploy log as evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vulnerability management.&lt;/strong&gt; Unattended security upgrades are enabled by default on provisioned servers, alongside UFW in deny-by-default mode and fail2ban. The hardened baseline isn't something you configure for the audit; it's the starting state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup and recovery.&lt;/strong&gt; Automated database backups run on schedule to S3-compatible storage with retention, giving you the schedule and retention evidence directly. The restore drills are still on you, and they should be; no platform can honestly automate away the act of verifying your own recoverability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Server monitoring keeps alert history with 30-day metric retention, so "show me your alerting worked during the window" is an export rather than an archaeology project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption.&lt;/strong&gt; Provider credentials and SSH private keys are encrypted at rest in the platform, which is one line in your confidentiality narrative you don't have to build yourself.&lt;/p&gt;

&lt;p&gt;Everything above is also reachable through the API, which matters more than it sounds: when your auditor or your compliance automation platform asks for evidence, a scripted export beats an afternoon of screenshots every single time. None of this makes you SOC 2 compliant on its own; no tool does. It means the infrastructure layer of your evidence list is largely handled, and your remaining work concentrates on policies, reviews, drills, and the application layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Long Does It Take, and What Does It Cost?
&lt;/h2&gt;

&lt;p&gt;Plan in months, not weeks, and plan for two cost centers: the auditor and the tooling.&lt;/p&gt;

&lt;p&gt;A realistic sequence for a small Laravel team looks like this. One to two months of readiness work: hardening gaps, policies, access reviews started, evidence collection wired up. Then the Type II observation window opens, typically three months for a first report (buyers sometimes discount a three-month window; six or twelve carries more weight on renewal). During the window, your job is simply to keep the controls running and the evidence flowing. After the window closes, the CPA firm performs fieldwork and writes the report, which adds several more weeks. End to end, six to nine months from "we should do this" to a Type II PDF in the sales team's hands is a sane expectation. Teams that need paper sooner slot a Type I in after readiness.&lt;/p&gt;

&lt;p&gt;On cost, we'll stay general rather than quote numbers that vary widely by firm and scope: you're paying a licensed CPA firm for the audit itself, usually annually, plus a subscription if you use a compliance automation platform. Together they're a meaningful line item for a small company, which is exactly why the trigger should be real enterprise demand rather than ambient anxiety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance automation platform (Vanta, Drata, Secureframe)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Continuous, API-pulled evidence collection; policy templates; auditor marketplaces; dramatically less manual gathering during the window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams going for Type II who want the observation window to document itself, and teams without a dedicated compliance person, which is every small team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; A real recurring cost, and the templates still need editing down to your reality. The platform collects evidence; it doesn't operate your controls for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual evidence collection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; No subscription cost. Forces deep familiarity with your own controls. Viable for a Type I with narrow scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams testing the waters with a Type I, or teams whose auditor provides a simple evidence request list and whose stack is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; For a Type II window, manual collection means months of remembering to capture things on schedule. One forgotten month is an exception in your report. Most teams that start manual switch before renewal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do Auditors Not Care About?
&lt;/h2&gt;

&lt;p&gt;Knowing what's out of scope saves real anxiety, so let's clear a few things up.&lt;/p&gt;

&lt;p&gt;They don't care that you use Laravel. Framework choice is invisible to the criteria. Nobody loses points for PHP, and nobody gains points for Rust. The auditor cares whether changes are reviewed, dependencies are scanned, and secrets are protected, in whatever stack you run.&lt;/p&gt;

&lt;p&gt;They don't care which cloud or VPS provider you use. DigitalOcean, Hetzner, AWS, a European provider chosen for data residency: all fine. What they'll ask for is your subprocessor list and your providers' own compliance reports (major infrastructure providers publish these), which slot into your evidence as inherited controls.&lt;/p&gt;

&lt;p&gt;They don't care that your team is small. There's no minimum headcount for SOC 2. Small teams do face specific friction, like separation of duties when the person writing code is also the person approving deploys, but auditors handle this constantly and there are accepted compensating controls, like logged self-review with after-the-fact spot checks.&lt;/p&gt;

&lt;p&gt;And they don't care about perfection. A report can include exceptions and still be valuable; a noted exception with a documented remediation reads as a functioning program, not a failing one. The report is a narrative of how you operate, not a pass/fail exam with a trophy at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do we need SOC 2 if we're a two-person Laravel startup?
&lt;/h3&gt;

&lt;p&gt;Not until your buyers ask for it. SOC 2 is a sales instrument, not a legal obligation. Do the underlying security work now, start keeping evidence habits early because they're cheap, and pull the trigger on an audit when enterprise questionnaires start arriving or a specific deal is gated on a report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can we pass SOC 2 on a VPS, or do we need a big cloud provider?
&lt;/h3&gt;

&lt;p&gt;You can absolutely pass on a VPS. The criteria are provider-agnostic; auditors evaluate your controls, not your vendor's logo. You'll list your infrastructure provider as a subprocessor and reference their compliance documentation, then evidence your own layer: access, patching, backups, monitoring, and change management.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type I first or straight to Type II?
&lt;/h3&gt;

&lt;p&gt;If no deal is burning, go straight for Type II: start the readiness work, open a three-month observation window, and you'll have the stronger report in roughly two quarters. If a deal is stalled today, a Type I plus a committed Type II date is a common and often accepted bridge.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the most commonly missing piece of evidence?
&lt;/h3&gt;

&lt;p&gt;Restore drills. Nearly every team can show backups exist; far fewer can show a dated record of restoring one and verifying the data. Quarterly drills with a one-line log entry each are the cheapest audit win available. Second place goes to offboarding records and quarterly access reviews.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does using Deploynix make us SOC 2 compliant?
&lt;/h3&gt;

&lt;p&gt;No, and be wary of any tool that claims otherwise. Deploynix gives you a hardened default baseline and exportable evidence for the infrastructure layer: action logs, deploy history, backup schedules, alert history. Policies, access reviews, drills, and the audit itself remain your work with a CPA firm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start This Week
&lt;/h2&gt;

&lt;p&gt;You don't start SOC 2 by calling an auditor. You start it by making your infrastructure provably boring: attributable access, logged changes, automatic patching, tested restores, and monitoring with a paper trail. Everything in this post compounds; evidence habits started this quarter are observation-window gold next year, and every gap you close is one less finding when a CPA firm finally looks.&lt;/p&gt;

&lt;p&gt;If you do one thing this week, make it a dry run: work through &lt;a href="https://deploynix.io/blog/hardening-your-deploynix-server-a-step-by-step-security-audit" rel="noopener noreferrer"&gt;a step-by-step security audit of your server&lt;/a&gt;, and for every control you verify, ask the auditor's question instead of the engineer's one. Not "does this work?" but "could I prove this worked in March?" The distance between those two answers is your entire SOC 2 project, measured honestly, before anyone bills you for the measuring.&lt;/p&gt;

</description>
      <category>soc2</category>
      <category>compliance</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>DigitalOcean vs. Vultr vs. Linode for Laravel: A Price and Performance Breakdown</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 03 Sep 2026 16:46:53 +0000</pubDate>
      <link>https://dev.to/deploynix/digitalocean-vs-vultr-vs-linode-for-laravel-a-price-and-performance-breakdown-5190</link>
      <guid>https://dev.to/deploynix/digitalocean-vs-vultr-vs-linode-for-laravel-a-price-and-performance-breakdown-5190</guid>
      <description>&lt;p&gt;Ask around any Laravel community where people host and you'll hear the same three names, with DigitalOcean usually first — a split that has less to do with performance than with marketing budgets and muscle memory. All three providers sell the same basic product: a KVM virtual machine with NVMe or SSD storage, a public IP, and an API. The differences that actually matter for a Laravel app hide in the details, and in 2026 those details are no longer on the pricing page headline.&lt;/p&gt;

&lt;p&gt;Here's the thing we've noticed provisioning servers on all three providers week in, week out: the entry prices have converged almost completely. Vultr's cheapest Cloud Compute plan costs $4 per month for 0.5 GB of RAM and 10 GB of NVMe storage, DigitalOcean's basic Droplet also starts around $4, and Linode's minimum sits near $5 but ships with a full 1 GB of RAM, the best price-to-memory ratio at the bottom tier (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte comparison&lt;/a&gt;, 2026). A dollar here or there won't decide anything. Egress fees, snapshot pricing, backup surcharges, and managed database costs will.&lt;/p&gt;

&lt;p&gt;So this isn't a generic VPS review. It's a Laravel-specific breakdown: what your app actually demands from a virtual machine, which provider wins at each budget, where the hidden costs live, and a real verdict for three common situations. If you want the wider decision framework first, our &lt;a href="https://deploynix.io/blog/the-laravel-developers-guide-to-choosing-a-cloud-provider-in-2026" rel="noopener noreferrer"&gt;guide to choosing a cloud provider in 2026&lt;/a&gt; covers the full field. Here, we're putting just these three head to head.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Laravel App Actually Need From a VPS?
&lt;/h2&gt;

&lt;p&gt;A Laravel app has a specific resource profile, and it's not the one most VPS marketing targets. Independent testing at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; shows all three providers trading blows on synthetic CPU scores, but Laravel cares about four things in a particular order: single-core speed, disk IOPS, RAM headroom, and egress allowance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-core speed for PHP-FPM.&lt;/strong&gt; PHP executes one request on one core. A request that takes 80ms on a fast core takes 140ms on a slow one, and no amount of extra cores fixes that for an individual user. This is why Vultr's High Frequency line and DigitalOcean's Premium (Intel/AMD) Droplets exist: same specs on paper, noticeably faster clock speeds and NVMe under load. When you benchmark, run a single-threaded test, not just a multi-core score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NVMe IOPS for MySQL.&lt;/strong&gt; Your database is the usual bottleneck in a Laravel app, and MySQL under write load is brutally sensitive to storage latency. All three providers now offer NVMe on their current-generation plans, but older "regular" tiers on Vultr and standard Droplets on DigitalOcean still use plain SSD. A quick way to check what you actually got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fio &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randwrite &lt;span class="nt"&gt;--ioengine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;libaio &lt;span class="nt"&gt;--iodepth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--rw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;randwrite &lt;span class="nt"&gt;--bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4k &lt;span class="nt"&gt;--size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1G &lt;span class="nt"&gt;--numjobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--direct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="nt"&gt;--group_reporting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a rough expectation rather than a hard rule: tens of thousands of random write IOPS is comfortable for a small production MySQL instance, while results in the low thousands will make themselves felt in queue processing and migrations. Compare your own numbers across the tiers you're considering — the relative gap matters more than any absolute threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAM headroom.&lt;/strong&gt; PHP-FPM workers, OPcache, MySQL's buffer pool, and Redis or Valkey all compete for memory. On a 1 GB box you're rationing. On 4 GB you can give MySQL a real buffer pool and still run 10 to 15 FPM workers. This is why the 4 GB tier, not the entry tier, is where most production Laravel apps end up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress for asset-heavy apps.&lt;/strong&gt; If you serve images, video, or large downloads directly from the server instead of a CDN or object storage, transfer allowance stops being a footnote. The three providers differ here more than anywhere else, and we'll put numbers on it below.&lt;/p&gt;

&lt;p&gt;Snapshots deserve an honorable mention too. Taking a snapshot before a risky deploy or a major version upgrade is cheap insurance, but only if snapshot pricing doesn't punish you for using it. More on that in the hidden costs section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entry Tier: Who Wins Under $6 for Side Projects?
&lt;/h2&gt;

&lt;p&gt;At the bottom of the price list, all three providers are within a dollar of each other, but the specs are not equal. At the time of writing, here's what your first $4 to $5 buys (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte comparison&lt;/a&gt;, 2026):&lt;/p&gt;

&lt;p&gt;ProviderPlanPrice/movCPURAMStorageTransferDigitalOceanBasic Droplet~$41512 MB10 GB SSD500 GBVultrCloud Compute$41512 MB10 GB NVMe~1 TBLinodeNanode 1 GB~$511 GB25 GB SSD1 TB&lt;/p&gt;

&lt;p&gt;Read that RAM column twice. Linode charges roughly a dollar more and hands you double the memory and more than double the storage. For a Laravel app, 512 MB is genuinely tight: after the OS, Nginx, PHP-FPM, and a minimal MySQL instance, you're flirting with the OOM killer every time Composer runs. We've watched &lt;code&gt;composer install&lt;/code&gt; fail on 512 MB boxes purely because MySQL and the install process couldn't coexist without swap.&lt;/p&gt;

&lt;p&gt;Our honest guidance for this tier: it's for side projects, staging boxes, and demos, and Linode's Nanode is the best Laravel fit of the three because 1 GB of RAM is the practical floor for running the full stack on one machine. Vultr's $4 plan wins if you offload the database elsewhere, since NVMe makes it snappy for what it is. DigitalOcean's $4 Droplet is the weakest deal on paper but comes attached to the best documentation ecosystem, which matters more than specs when it's your first server.&lt;/p&gt;

&lt;p&gt;Don't underestimate what these small boxes can do, though. We load tested this exact class of machine in &lt;a href="https://deploynix.io/blog/how-much-traffic-can-a-5-server-handle-load-testing-laravel-on-deploynix" rel="noopener noreferrer"&gt;how much traffic a $5 server can handle&lt;/a&gt;, and a tuned Laravel app with OPcache and proper caching served hundreds of requests per second before falling over. The entry tier isn't a toy. It's just not where you want your database living long-term.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 GB / 2 vCPU Tier: Where Most Production Laravel Apps Live
&lt;/h2&gt;

&lt;p&gt;Ask us where the median production Laravel app runs and the answer is boring: one server, 4 GB RAM, 2 vCPUs, everything on it. That's enough for MySQL with a 1 GB buffer pool, Valkey or Redis for cache and sessions, a dozen PHP-FPM workers, a queue worker or two, and the scheduler. At the time of writing, the class shakes out like this:&lt;/p&gt;

&lt;p&gt;ProviderPlanPrice/movCPURAMStorageTransferDigitalOceanBasic Droplet (Regular)$2424 GB80 GB SSD4 TBDigitalOceanBasic Droplet (Premium NVMe)$2824 GB80 GB NVMe4 TBVultrCloud Compute (Regular)$2024 GB80 GB SSD3 TBVultrHigh Frequency$2424 GB128 GB NVMe3 TBLinodeLinode 4 GB (Shared)$2424 GB80 GB SSD4 TB&lt;/p&gt;

&lt;p&gt;Three observations from running Laravel workloads on all five of those plans.&lt;/p&gt;

&lt;p&gt;First, Vultr is the price aggressor. Twenty dollars for the regular plan undercuts everyone, and the High Frequency plan at $24 gives you 128 GB of NVMe, 60% more storage than the equivalent Droplet, at the same price. If raw specs per dollar decide your purchase, Vultr takes this tier, which matches its reputation for the most aggressive pricing in the segment (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes comparison&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Second, DigitalOcean's Premium NVMe surcharge is worth paying for Laravel. The $4 difference between regular and Premium buys newer CPUs and NVMe storage, and in our experience that translates to visibly lower p95 response times under database load. If you're on DigitalOcean, don't provision the regular plan for production in 2026.&lt;/p&gt;

&lt;p&gt;Third, Linode's 4 TB transfer at $24 quietly matters. It matches DigitalOcean and beats Vultr by a terabyte, and Linode has historically been generous when pooling transfer across servers on the same account. For an app serving user uploads or media without a CDN in front, that extra headroom is real money.&lt;/p&gt;

&lt;p&gt;Performance-wise, independent trials at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; show the three providers within a band of each other on web workloads, with rankings shifting between test runs and regions. Nobody wins this tier on performance alone. The differentiation is price (Vultr), transfer and support (Linode), and ecosystem (DigitalOcean).&lt;/p&gt;

&lt;h2&gt;
  
  
  DigitalOcean: The Ecosystem Default
&lt;/h2&gt;

&lt;p&gt;DigitalOcean is where most Laravel developers start, and there's a defensible reason: no provider has invested more in documentation. The community tutorial library covers nearly every Nginx, MySQL, and PHP configuration question you'll ever have, and it's usually the first search result when something breaks at 2 a.m. Industry comparisons consistently rank DigitalOcean first on ecosystem, docs, and marketplace breadth among these three (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Beyond Droplets, the product surface is the widest here: managed MySQL and Postgres, Spaces object storage with a built-in CDN, load balancers, VPC networking, a container registry, and a marketplace of one-click images. For a Laravel team, that means your app server, your S3-compatible backup target, and your managed database can all live in one account with private networking between them.&lt;/p&gt;

&lt;p&gt;The API is the best of the three in practice. It's consistent, well-documented, versioned, and wrapped by mature client libraries in every language that matters, including solid PHP SDKs. Terraform and Packer support is first-class. If you automate infrastructure, DigitalOcean is the least likely to surprise you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Best documentation and community content in the industry; broadest product ecosystem (managed databases, Spaces, load balancers, VPC); most polished API and tooling; Premium NVMe Droplets are genuinely fast for PHP workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams and agencies that value predictability and ecosystem over squeezing the last dollar; developers who want managed MySQL and object storage from the same vendor; anyone who leans on community tutorials when debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Rarely the cheapest option at any tier; the regular (non-Premium) Droplets use older hardware you should avoid for production; 500 GB transfer on the entry Droplet is the stingiest of the three; support responsiveness on lower tiers is ticket-based and can be slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vultr: The Price and Regions Aggressor
&lt;/h2&gt;

&lt;p&gt;Vultr's pitch is simple: more locations and lower prices than anyone else in this weight class. At the time of writing it operates 30+ regions, the most of the three by a wide margin, with locations the others don't touch, including São Paulo, Johannesburg, Mumbai, Seoul, and multiple Australian cities (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). If your users are in a geography DigitalOcean and Linode treat as an afterthought, Vultr may be the only one of the three with a data center nearby.&lt;/p&gt;

&lt;p&gt;The pricing aggression is real, not just marketing. The $4 entry plan with NVMe, the $20 regular 4 GB plan, and the High Frequency line all undercut or out-spec the equivalent products next door. Vultr also sells niche products the others don't at this price point: bare metal servers, GPU instances, and dedicated-CPU plans with fine-grained sizing.&lt;/p&gt;

&lt;p&gt;Where does Laravel fit? The High Frequency plans are the sweet spot. Fast clocks help PHP-FPM directly, and the oversized NVMe allocation gives MySQL room to breathe. We've found Vultr instances perform very close to DigitalOcean Premium Droplets at a slightly lower price, with the caveat that performance consistency varies more by region than it does on the other two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Most aggressive pricing of the three at nearly every tier; most regions by far, with real APAC, South America, and Africa coverage; High Frequency NVMe plans are excellent for PHP and MySQL; broad product range including bare metal and GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Cost-sensitive builders who compare spec sheets; apps whose users live outside the US/EU corridor; developers who offload the database and want the cheapest fast app server possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Ecosystem and documentation depth trail DigitalOcean noticeably; performance consistency varies more between regions; snapshots and some features that were once free are now billed; support reputation is adequate rather than praised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linode (Akamai): The Steady Operator
&lt;/h2&gt;

&lt;p&gt;Linode is the oldest of the three, and since the Akamai acquisition it's become the "boring in a good way" option. Comparisons consistently highlight two things: generous transfer allowances and a long-standing reputation for reachable, human support (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). Both matter more in month twelve of running an app than they do on day one.&lt;/p&gt;

&lt;p&gt;The entry tier is where Linode's value is most obvious. The ~$5 Nanode's 1 GB of RAM makes it the best price-to-memory deal at the bottom of the market (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte&lt;/a&gt;, &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt;), and 1 GB is precisely the line where a full single-server Laravel stack becomes comfortable instead of precarious. For staging environments and client demo servers, we reach for Nanodes constantly.&lt;/p&gt;

&lt;p&gt;The Akamai connection adds something the other two can't match: the backbone. Linode traffic rides Akamai's network, which shows up as consistently good routing and peering, particularly for globally distributed users hitting a single origin. Akamai has also been expanding Linode's region list into its edge footprint, closing what used to be Linode's biggest gap against Vultr.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; Best RAM per dollar at the entry tier; generous 4 TB transfer at the 4 GB tier with account-level pooling; support reputation is the best of the three; Akamai's network and backbone; predictable, stable pricing history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Hobby projects and staging servers where 1 GB of RAM at $5 is the whole decision; transfer-heavy apps serving media from the origin; teams that want to talk to a human when something breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Managed database offering has had a bumpy history (paused, then relaunched under Akamai), so verify current availability in your region before depending on it; the product ecosystem is narrower than DigitalOcean's; the dashboard and marketplace feel a generation behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Do the Hidden Costs Hide?
&lt;/h2&gt;

&lt;p&gt;Here's the 2026 nuance that changed how we advise people: the headline compute price stopped being the whole story. Egress overages, snapshot storage, backup surcharges, and managed add-ons now decide the real monthly bill more often than the base plan does (&lt;a href="https://apicalculators.com/blog/cloud-vps-cost-comparison-2026" rel="noopener noreferrer"&gt;apicalculators&lt;/a&gt;, 2026). Two identical $24 servers can produce $24 and $47 invoices depending on how you use snapshots and where your traffic goes.&lt;/p&gt;

&lt;p&gt;At the time of writing, the side costs stack up like this:&lt;/p&gt;

&lt;p&gt;Cost itemDigitalOceanVultrLinodeEgress overage (per GB)$0.01$0.01$0.005Snapshots$0.06/GB/mo$0.05/GB/moNo standalone snapshots (use Images at ~$0.10/GB/mo)Automated backups+20% of plan (weekly), +30% (daily)+20% of planFlat fee per plan size (e.g., ~$5/mo on a 4 GB Linode)Included transfer (4 GB tier)4 TB3 TB4 TB (pooled)&lt;/p&gt;

&lt;p&gt;A worked example makes it concrete. Take a $24 server, weekly provider backups, one 60 GB snapshot kept as a pre-upgrade restore point, and 1 TB of overage because your app serves user-uploaded video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DigitalOcean: $24 + $4.80 (backups) + $3.60 (snapshot) + $10.00 (overage) = $42.40
Vultr:        $24 + $4.80 (backups) + $3.00 (snapshot) + $10.00 (overage) = $41.80
Linode:       $24 + $5.00 (backups) + $6.00 (image)    + $5.00  (overage) = $40.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap between providers is small. The gap between the sticker price and the real bill is not: that's a 67% to 77% markup over the base plan in every case. Three practical mitigations, whichever provider you pick: put a CDN in front of anything asset-heavy so overage never triggers, delete snapshots after the upgrade they were insuring succeeds, and consider replacing the provider's percentage-based backup product with your own dump-and-ship pipeline to S3-compatible storage, which typically costs $1 to $2 per month for the same data.&lt;/p&gt;

&lt;p&gt;One more subtle difference: Linode's per-GB egress overage is half the price of the other two. For origin-served media at scale, that halves your most unpredictable line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed Database or a Second VPS?
&lt;/h2&gt;

&lt;p&gt;At some point every growing Laravel app faces the same fork: move MySQL to the provider's managed database product, or self-host it on a second VPS. The price gap is bigger than most people expect.&lt;/p&gt;

&lt;p&gt;At the time of writing, DigitalOcean's managed MySQL starts around $15 per month for a 1 GB single-node cluster, with high-availability configurations (a primary plus standby) starting north of $50. Vultr and Linode's managed database offerings land in a similar band, with entry plans in the $15 to $35 range depending on engine and region, though Linode's product has been through a pause-and-relaunch cycle under Akamai and deserves a current-availability check before you commit.&lt;/p&gt;

&lt;p&gt;Compare that to self-hosting: a dedicated $6 to $12 VPS running only MySQL, with the buffer pool tuned to the box, private networking to your app server, and nightly dumps shipped off-site. Roughly half the cost of the cheapest managed tier, and a quarter of the HA tier.&lt;/p&gt;

&lt;p&gt;So what does the managed premium buy? Automated failover, point-in-time recovery, patching, and one less thing to page you. Those are real. But be honest about what the entry-level managed plans actually include: the $15 single-node plans have no standby, so you're paying the premium without getting the failover. In our experience, the managed option earns its cost at the HA tier and above, when downtime is measured in lost revenue. Below that, a second VPS with automated backups covers the same risk for less, and it's the pattern we see most $10-to-$50-per-month Laravel apps settle on. Teams doing &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation from AWS to a $10 VPS&lt;/a&gt; make exactly this trade deliberately, swapping RDS convenience for a bill that's an order of magnitude smaller.&lt;/p&gt;

&lt;p&gt;The Laravel-specific footnote: whichever route you choose, keep the database in the same region and on the same private network as the app server. A 30ms round trip between app and database turns a 12-query page into a 360ms page before PHP does any work at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regions and Latency: Who Covers Your Users?
&lt;/h2&gt;

&lt;p&gt;Region choice is a Laravel performance decision disguised as a dropdown menu. TTFB for an uncached page is dominated by the round trip to your origin, so put the server near your users, not near you.&lt;/p&gt;

&lt;p&gt;The coverage differences at the time of writing are stark. Vultr fields 30+ locations, the widest map of the three, with meaningful presence in South America (São Paulo), Africa (Johannesburg), India (Mumbai, Delhi region), Korea, Japan, and Australia (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). DigitalOcean runs a tighter list of around 15 regions: strong US and EU coverage, plus Singapore, Bangalore, Sydney, and Toronto, but nothing in South America or Africa. Linode's classic list (US, EU, Singapore, Tokyo, Mumbai, Sydney) has been expanding under Akamai into new metros riding the Akamai backbone, and routing quality on that backbone is a quiet advantage even where the region count is lower.&lt;/p&gt;

&lt;p&gt;Practical translations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;US or EU audience: all three cover you well; pick on price and ecosystem, not geography.&lt;/li&gt;
&lt;li&gt;APAC audience: Vultr's breadth wins (Seoul, Osaka, multiple Australian cities); Linode's Tokyo and Singapore locations are solid seconds with excellent peering.&lt;/li&gt;
&lt;li&gt;South America or Africa: Vultr is the only one of the three with in-region compute; the others route you to US East or EU, adding 100 to 200ms per round trip.&lt;/li&gt;
&lt;li&gt;Latency-sensitive split audiences: consider two small servers in two regions behind GeoDNS rather than one big server in the middle. Two Nanodes cost $10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if your audience is European and budget is the top priority, we'd be doing you a disservice not to mention that &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo-for-laravel-in-europe" rel="noopener noreferrer"&gt;Hetzner undercuts all three on price-to-performance in the EU&lt;/a&gt;, often by 2x or more at the same price point. That's a separate post because it's a separate conversation, but it's the honest asterisk on this entire comparison for EU-first apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does API Quality Actually Matter?
&lt;/h2&gt;

&lt;p&gt;If you ever provision more than one server, yes. The API is the difference between infrastructure you can rebuild from a script and infrastructure that exists because someone clicked buttons eighteen months ago.&lt;/p&gt;

&lt;p&gt;All three providers expose full REST APIs and official CLIs, and all three are fully supported by Terraform. Creating a server is a one-liner on each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DigitalOcean&lt;/span&gt;
doctl compute droplet create app-01 &lt;span class="nt"&gt;--size&lt;/span&gt; s-2vcpu-4gb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-24-04-x64 &lt;span class="nt"&gt;--region&lt;/span&gt; fra1

&lt;span class="c"&gt;# Vultr&lt;/span&gt;
vultr-cli instance create &lt;span class="nt"&gt;--plan&lt;/span&gt; vhf-2c-4gb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--os&lt;/span&gt; 2284 &lt;span class="nt"&gt;--region&lt;/span&gt; fra &lt;span class="nt"&gt;--label&lt;/span&gt; app-01

&lt;span class="c"&gt;# Linode&lt;/span&gt;
linode-cli linodes create &lt;span class="nt"&gt;--type&lt;/span&gt; g6-standard-2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; linode/ubuntu24.04 &lt;span class="nt"&gt;--region&lt;/span&gt; eu-central &lt;span class="nt"&gt;--label&lt;/span&gt; app-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The differences show up past the happy path. DigitalOcean's API is the most consistent and best documented, with predictable pagination, useful error messages, and mature SDKs; it's the one we'd hand to a junior engineer without a walkthrough. Linode's API v4 is clean and well-designed, arguably the nicest REST semantics of the three, with an excellent official CLI. Vultr's API covers everything but carries more legacy quirks: some operations behave asynchronously without clearly saying so, and you'll write more polling loops than you would against the other two.&lt;/p&gt;

&lt;p&gt;For a solo developer this is a tiebreaker at most. For an agency provisioning a server per client, or a platform (like ours) that talks to all three APIs every day, it compounds: webhook reliability, rate limit sanity, and error clarity determine how much retry-and-reconcile code you end up maintaining. Ecosystem rankings that put DigitalOcean first on tooling reflect our experience exactly (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Running All Three on Deploynix
&lt;/h2&gt;

&lt;p&gt;We'll declare our position: we don't think you should have to marry a provider. DigitalOcean, Vultr, and Linode are all first-class providers on Deploynix, alongside Hetzner, AWS, and custom servers, and the provisioning flow is identical on each. You connect an API token, pick a server type (app, web, database, cache, worker, Meilisearch, or load balancer), pick a plan and region, and get the same hardened stack with monitoring, free SSL, backups to S3-compatible storage, and zero-downtime deploys, regardless of whose logo is on the invoice.&lt;/p&gt;

&lt;p&gt;That symmetry changes the economics of this whole comparison. When switching costs are near zero, you can pick per workload instead of per vendor: a Vultr High Frequency box for the app server because the price is right, a Linode for the staging environment because the Nanode is the best $5 in the business, and DigitalOcean managed MySQL because the ecosystem argument won for your database. One dashboard, one deploy pipeline, three providers. We wrote up how that works day-to-day in &lt;a href="https://deploynix.io/blog/managing-servers-across-6-cloud-providers-from-one-dashboard" rel="noopener noreferrer"&gt;managing servers across six cloud providers from one dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It also makes migration boring, which is the highest compliment infrastructure can receive. Moving a Laravel app from DigitalOcean to Vultr (or the reverse) is: provision an equivalent server on the new provider, deploy the same app to it from the same Git repo, import the database, and cut DNS over once you've verified the new box. No re-learning a provisioning system, no rewritten deploy scripts, no config drift between the old and new servers, because both were built from the same recipe. Teams do this to chase a better price, escape a noisy region, or consolidate billing, and it's an afternoon, not a quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verdict: Match the Provider to the Job
&lt;/h2&gt;

&lt;p&gt;We promised real verdicts, so here they are, based on the numbers above and our own operational experience across all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hobby project or side project: Linode.&lt;/strong&gt; The ~$5 Nanode's 1 GB of RAM is the entire argument. It's the only entry-tier plan of the three that runs a full Laravel stack (Nginx, PHP-FPM, MySQL, Redis) without constant memory anxiety, and the price-to-memory math is the best at the bottom of the market (&lt;a href="https://valebyte.com/en/blog/vultr-vs-linode-vs-digitalocean-which-cloud-vps-to-choose-in-2026/" rel="noopener noreferrer"&gt;Valebyte&lt;/a&gt;). Vultr's $4 NVMe plan is the runner-up if your database lives elsewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small SaaS in production: Vultr High Frequency, with DigitalOcean Premium a close second.&lt;/strong&gt; At the 4 GB / 2 vCPU tier where most Laravel apps live, Vultr's $24 High Frequency plan pairs fast clocks with 128 GB of NVMe and the lowest effective price. Choose DigitalOcean instead if you want managed MySQL, Spaces, and load balancers from the same account, and accept a few dollars of ecosystem tax. Watch the transfer line: if you serve heavy assets from the origin, Linode's pooled 4 TB and half-price overage quietly wins the real-bill comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agency running many client servers: DigitalOcean.&lt;/strong&gt; When you're operating twenty servers for twelve clients, docs, API polish, and product breadth beat a $4-per-server saving. The best tutorial library in the industry reduces your support burden, and the cleanest API reduces your automation burden (&lt;a href="https://www.ssdnodes.com/blog/digitalocean-vs-linode-vs-vultr/" rel="noopener noreferrer"&gt;SSD Nodes&lt;/a&gt;). Keep Vultr in your pocket for clients with users in regions DigitalOcean doesn't reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EU-first and budget-driven: none of the above.&lt;/strong&gt; Hetzner beats all three on raw price-to-performance in Europe, and pretending otherwise would make the rest of this post less trustworthy. The &lt;a href="https://deploynix.io/blog/hetzner-deploynix-the-best-price-to-performance-combo-for-laravel-in-europe" rel="noopener noreferrer"&gt;full Hetzner breakdown&lt;/a&gt; has the numbers.&lt;/p&gt;

&lt;p&gt;The pattern behind all four verdicts: compute prices converged, so pick on the second-order factors that fit your situation, and keep your setup portable enough that the decision stays reversible.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which provider is fastest for Laravel specifically?
&lt;/h3&gt;

&lt;p&gt;There's no consistent winner. Independent tests at &lt;a href="https://www.vpsbenchmarks.com/compare/docean_vs_linode_vs_vultr" rel="noopener noreferrer"&gt;VPSBenchmarks&lt;/a&gt; show the three trading places by region and test run. What moves the needle for Laravel is plan generation, not vendor: pick NVMe-backed, current-CPU plans (Vultr High Frequency, DigitalOcean Premium, Linode's current shared tier) and you'll be within a few percent either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is 1 GB of RAM enough to run Laravel in production?
&lt;/h3&gt;

&lt;p&gt;For a low-traffic app, yes, if the whole stack is tuned for it: 3 to 4 PHP-FPM workers, a capped MySQL buffer pool, and OPcache enabled. It's the practical floor, which is why Linode's 1 GB entry plan beats the 512 MB plans from DigitalOcean and Vultr for single-server setups. For anything with real users or queue volume, budget for the 4 GB tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use managed MySQL or run the database on my own VPS?
&lt;/h3&gt;

&lt;p&gt;Self-host until downtime costs you real money. Entry managed plans (from roughly $15/month at the time of writing) are single-node, so you pay the premium without getting failover. A $6 to $12 dedicated database VPS with automated off-site backups covers the same risk for half the price. Move to managed once you need the HA tier with a standby node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I mix providers, say the app on Vultr and staging on Linode?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's more practical than it sounds. Since Deploynix provisions DigitalOcean, Vultr, Linode, Hetzner, AWS, and custom servers through the identical flow, mixed setups behave like one environment: same deploy pipeline, same monitoring, same backup targets. The only real constraint is keeping the app and its database in the same region and network, whichever provider that is.&lt;/p&gt;

&lt;h3&gt;
  
  
  How painful is it to switch providers later?
&lt;/h3&gt;

&lt;p&gt;If your server was built from a reproducible recipe, it's an afternoon: provision the new server, deploy from the same repo, import the database, verify, and cut DNS over with a low TTL. The pain people remember comes from hand-configured servers where nobody knows what's on them. Keep the setup scripted or platform-managed and provider choice stays a reversible decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick One and Ship
&lt;/h2&gt;

&lt;p&gt;Here's our closing advice: the cost of deliberating over these three for another week is higher than the cost of picking the "wrong" one. The prices are within dollars of each other, the performance is within percent, and a well-scripted setup makes the choice reversible in an afternoon. Apply the verdicts above (Linode for the $5 experiment, Vultr or DigitalOcean for the production 4 GB box, DigitalOcean for the agency fleet), then spend your energy on the app instead of the vendor.&lt;/p&gt;

&lt;p&gt;Where to go from here: pick the provider that matched your situation, grab an API token, and &lt;a href="https://deploynix.io/blog/deploy-your-first-laravel-app-in-under-10-minutes-with-deploynix" rel="noopener noreferrer"&gt;deploy your first Laravel app in under 10 minutes&lt;/a&gt;. You'll learn more from one deployed server than from ten more comparison posts, including this one.&lt;/p&gt;

</description>
      <category>digitalocean</category>
      <category>vultr</category>
      <category>linode</category>
      <category>comparison</category>
    </item>
    <item>
      <title>Migrating From Laravel Vapor to Deploynix: Trading Serverless for Servers</title>
      <dc:creator>Deploynix</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:39:33 +0000</pubDate>
      <link>https://dev.to/deploynix/migrating-from-laravel-vapor-to-deploynix-trading-serverless-for-servers-3f4o</link>
      <guid>https://dev.to/deploynix/migrating-from-laravel-vapor-to-deploynix-trading-serverless-for-servers-3f4o</guid>
      <description>&lt;p&gt;There is a specific moment when serverless economics invert, and most teams can name the month it happened. Your Laravel app used to have spiky, unpredictable traffic, and Lambda's pay-per-millisecond model was a bargain. Then the product found its audience. Traffic became steady: a predictable daily curve, the same few thousand users, the same queue volume every hour. From that point on, you are paying a premium for elasticity you no longer use. The bill stops scaling to zero because your traffic never does.&lt;/p&gt;

&lt;p&gt;We have talked to a lot of Laravel teams in exactly this position. They chose Vapor for good reasons, and Vapor delivered. But somewhere between product-market fit and the third month of a four-figure AWS invoice, someone opens Cost Explorer, squints at the NAT gateway line item, and asks the uncomfortable question: what would this cost on two ordinary servers?&lt;/p&gt;

&lt;p&gt;Usually the answer is 60 to 80 percent less, with faster p50 latencies and a log file you can actually tail. This post is the honest version of that conversation. We will cover when Vapor is still the right call, how to audit your app before moving, a step-by-step migration runbook including a queue cutover that does not lose jobs, and a worked cost comparison at steady load. If you want the broader argument first, we made it in &lt;a href="https://deploynix.io/blog/the-serverless-hype-vs-reality-for-laravel-apps" rel="noopener noreferrer"&gt;the serverless hype vs. reality for Laravel apps&lt;/a&gt;. This post assumes you have already decided to look seriously at moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Vapor Is the Right Call
&lt;/h2&gt;

&lt;p&gt;Let's be fair before we get to the migration, because Vapor is a genuinely well-built product and some teams should absolutely stay on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;True burst handling. If your traffic goes from 50 requests per minute to 5,000 in thirty seconds, Lambda absorbs it without you touching anything. No autoscaling groups, no capacity planning, no 3 a.m. pages about load. This is the killer feature, and no fixed-size server setup matches it.&lt;/li&gt;
&lt;li&gt;Zero server operations. No OS patching, no PHP upgrades on a box, no disk space alerts, no SSH. For a solo founder or a team with no infrastructure appetite, that has real value.&lt;/li&gt;
&lt;li&gt;Per-millisecond billing on idle apps. A staging environment or an internal tool that gets ten requests a day costs almost nothing on Lambda. A VPS costs the same whether it serves one request or one million.&lt;/li&gt;
&lt;li&gt;First-party Laravel integration. Vapor is built by the Laravel team. Queues, scheduled tasks, environment management, and deployments all work the way the framework documentation assumes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Apps with genuinely spiky or unpredictable traffic (ticket sales, viral consumer products, campaign-driven marketing sites), teams with zero ops capacity, and low-traffic apps where the bill really does round to pocket change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Steady traffic means you pay the serverless premium continuously for elasticity you use twice a year. Cold starts of 500ms to 2 seconds hit your least-trafficked endpoints hardest, which are often admin panels and API routes your paying customers notice. Lambda's 15-minute execution ceiling is a hard wall for long imports, big report generation, and video processing. NAT gateway hours and data processing charges appear as surprise line items the moment your functions talk to the internet from inside a VPC. And debugging means CloudWatch Logs Insights queries instead of &lt;code&gt;tail -f storage/logs/laravel.log&lt;/code&gt;, which sounds minor until you are doing it during an incident.&lt;/p&gt;

&lt;p&gt;The pattern is not unique to Vapor users. A Barclays CIO survey found that 83% of enterprises plan to repatriate at least some workloads from public cloud (via &lt;a href="https://northflank.com/blog/cloud-repatriation" rel="noopener noreferrer"&gt;Northflank&lt;/a&gt;), and teams that repatriate strategically report infrastructure savings of 30 to 60 percent (&lt;a href="https://massivegrid.com/blog/cloud-repatriation-2026-moving-off-aws-azure-gcp/" rel="noopener noreferrer"&gt;MassiveGRID&lt;/a&gt;). Worth stressing, though: only around 5% plan a full exit from the cloud (&lt;a href="https://advancedhosting.com/cloud-repatriation/" rel="noopener noreferrer"&gt;Advanced Hosting&lt;/a&gt;). Repatriation is a workload-by-workload decision, not an ideology. Your steady-traffic Laravel monolith is exactly the kind of workload where it pays off. Your S3 buckets and maybe your RDS instance can stay right where they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Know You've Hit the Crossover Point?
&lt;/h2&gt;

&lt;p&gt;The crossover is rarely one dramatic invoice. It creeps. Here are the signals we see most often in teams that eventually migrate, roughly in the order they appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your warming bill exists at all.&lt;/strong&gt; Vapor's &lt;code&gt;warm&lt;/code&gt; setting keeps Lambda containers alive so users do not eat cold starts. Read that again: you are paying for idle compute to simulate a server. Once warming instances run around the clock, you have reinvented a fixed server with worse economics. The moment &lt;code&gt;warm: 10&lt;/code&gt; feels necessary in production, the elasticity argument has already lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts hit the endpoints you can least afford.&lt;/strong&gt; High-traffic routes stay warm naturally. It is the low-traffic ones that go cold: the admin panel your biggest customer's ops team uses, the API endpoint a partner polls hourly, the password reset flow. A 1.5-second stall on a checkout-adjacent route is a support ticket; on a sales demo, it is worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue backlog keeps hitting the 15-minute wall.&lt;/strong&gt; If your team has ever chunked a job, not because chunking was good design but because Lambda would kill it at minute fifteen, count how many of those workarounds exist. Each one is architecture bent around a billing model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invoice needs a translator.&lt;/strong&gt; When "why did the NAT gateway cost $60 this month?" is a real question in your Slack, and answering it takes an hour in Cost Explorer, add that engineering time to the bill. Same for incidents: if reconstructing one request means stitching together three CloudWatch log groups, your mean-time-to-understanding is paying the serverless tax too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traffic graphs look like sine waves, not earthquakes.&lt;/strong&gt; Pull 90 days of request metrics. If peak hour is within 3 to 5x of the quiet hour, every week, you have a capacity-plannable app. Provision a server for peak with headroom and pocket the difference.&lt;/p&gt;

&lt;p&gt;Two or three of these and you are past the crossover. All five, and this migration is overdue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the Vapor-to-Server Concept Mapping Look Like?
&lt;/h2&gt;

&lt;p&gt;The good news: nothing about Vapor changes your application code in ways that block a move. Laravel was built for servers first. Vapor adapted it to Lambda; you are adapting it back. Every Vapor concept has a direct server-side equivalent.&lt;/p&gt;

&lt;p&gt;Vapor / AWS concept&lt;/p&gt;

&lt;p&gt;Server equivalent&lt;/p&gt;

&lt;p&gt;Notes&lt;/p&gt;

&lt;p&gt;Lambda HTTP function&lt;/p&gt;

&lt;p&gt;Nginx + PHP-FPM worker pool (or Octane)&lt;/p&gt;

&lt;p&gt;Always warm. No cold starts, no per-invocation billing.&lt;/p&gt;

&lt;p&gt;SQS queues&lt;/p&gt;

&lt;p&gt;Database or Valkey/Redis queue + Supervisor-managed workers&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt; or &lt;code&gt;redis&lt;/code&gt;. Workers restart automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vapor.yml&lt;/code&gt; build/deploy steps&lt;/p&gt;

&lt;p&gt;Deploynix deploy config + deploy hooks&lt;/p&gt;

&lt;p&gt;Same idea: composer install, npm build, migrate, cache.&lt;/p&gt;

&lt;p&gt;RDS database&lt;/p&gt;

&lt;p&gt;Stays on RDS initially, or moves to a database server later&lt;/p&gt;

&lt;p&gt;Connecting to RDS from a VPS is fine. Decouple this decision from the migration.&lt;/p&gt;

&lt;p&gt;CloudFront&lt;/p&gt;

&lt;p&gt;Cloudflare (or any CDN) in front of your server&lt;/p&gt;

&lt;p&gt;Free tier covers most Laravel apps.&lt;/p&gt;

&lt;p&gt;Vapor environment variables and secrets&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; per environment, managed in the dashboard&lt;/p&gt;

&lt;p&gt;Same values, different transport.&lt;/p&gt;

&lt;p&gt;Scheduled tasks (&lt;code&gt;scheduler: true&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Cron entry running &lt;code&gt;php artisan schedule:run&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One line, managed via UI.&lt;/p&gt;

&lt;p&gt;ElastiCache / Vapor cache&lt;/p&gt;

&lt;p&gt;Valkey on the same box or a dedicated cache server&lt;/p&gt;

&lt;p&gt;Sub-millisecond, no network hop for single-server setups.&lt;/p&gt;

&lt;p&gt;CloudWatch Logs&lt;/p&gt;

&lt;p&gt;&lt;code&gt;storage/logs/laravel.log&lt;/code&gt; + log shipping if you want it&lt;/p&gt;

&lt;p&gt;You can tail it again.&lt;/p&gt;

&lt;p&gt;Two things on this table deserve emphasis. First, &lt;strong&gt;RDS can stay&lt;/strong&gt;. A common migration mistake is trying to move compute and data on the same day. Don't. Deploynix provisions servers on AWS as well as DigitalOcean, Vultr, Linode, and Hetzner, so you can put your app server in the same AWS region as your RDS instance, keep single-digit-millisecond database latency, and defer the database decision entirely. Second, &lt;strong&gt;S3 stays S3&lt;/strong&gt;. Your &lt;code&gt;FILESYSTEM_DISK=s3&lt;/code&gt; config does not care whether the code calling it runs on Lambda or a VPS. Do not migrate object storage as part of this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Migration Audit
&lt;/h2&gt;

&lt;p&gt;An afternoon of auditing saves a weekend of firefighting. Before provisioning anything, work through your &lt;code&gt;vapor.yml&lt;/code&gt; and your codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inventory your vapor.yml
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;vapor.yml&lt;/code&gt; is the map of everything the migration has to replace. A typical mid-size app looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12345&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-app&lt;/span&gt;
&lt;span class="na"&gt;environments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;
    &lt;span class="na"&gt;cli-memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;
    &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-8.3&lt;/span&gt;
    &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;notifications&lt;/span&gt;
    &lt;span class="na"&gt;queue-memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;
    &lt;span class="na"&gt;queue-timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
    &lt;span class="na"&gt;warm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-production&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme-cache&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;composer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;install&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--no-dev'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;event:cache'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ci&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rm&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-rf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;node_modules'&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;php&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;artisan&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--force'&lt;/span&gt;
  &lt;span class="na"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;
    &lt;span class="na"&gt;queues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
    &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each environment, write down: the queue names and their timeouts, whether the scheduler is on, the attached database and cache, every build and deploy step, and the warming configuration (that &lt;code&gt;warm: 10&lt;/code&gt; line is ten Lambda containers being pinged around the clock; it becomes irrelevant on a server, and it was costing you money). Then pull the environment variables and secrets for each environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vapor &lt;span class="nb"&gt;env&lt;/span&gt;:pull production
&lt;span class="c"&gt;# secrets are listed in the Vapor dashboard; export them alongside the env file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the result somewhere safe. This file becomes your server &lt;code&gt;.env&lt;/code&gt; almost verbatim.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find Lambda-shaped code assumptions
&lt;/h3&gt;

&lt;p&gt;Vapor forced some patterns onto your codebase. Most of them are harmless on a server, and a few actually become opportunities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ephemeral &lt;code&gt;/tmp&lt;/code&gt; workarounds. Code that streams uploads straight to S3 because local disk did not persist keeps working unchanged. But jobs that awkwardly chunked work through S3 to dodge the missing filesystem can be simplified: you have a persistent local disk again.&lt;/li&gt;
&lt;li&gt;The 15-minute ceiling. Search for jobs that were split into chains purely to fit under Lambda's timeout. On a server, a queue worker can run a four-hour job if you let it. You do not have to refactor these on day one, but flag them.&lt;/li&gt;
&lt;li&gt;Package and image size limits. Vapor's Docker runtime caps images at 10GB and the standard runtime is far tighter, so you may have excluded binaries like &lt;code&gt;wkhtmltopdf&lt;/code&gt;, FFmpeg, or heavy PHP extensions. On a server you just install them.&lt;/li&gt;
&lt;li&gt;Runtime assumptions. Check for hardcoded references to Vapor helpers or the &lt;code&gt;VAPOR_*&lt;/code&gt; environment variables, and confirm &lt;code&gt;TRUSTED_PROXIES&lt;/code&gt; handling once you are behind Cloudflare instead of CloudFront.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Decide where the database lives (for now)
&lt;/h3&gt;

&lt;p&gt;The right answer during migration is: exactly where it is today. Add your VPS's IP to the RDS security group, point &lt;code&gt;DB_HOST&lt;/code&gt; at the RDS endpoint, and move on. Once the migration has been stable for a month, you can evaluate moving to a Deploynix-provisioned database server with automated backups shipped to S3-compatible storage. That is a separate project with its own dump-and-restore cutover, and we cover the economics of it in &lt;a href="https://deploynix.io/blog/the-real-cost-of-running-a-laravel-saas-monthly-infrastructure-breakdown" rel="noopener noreferrer"&gt;the real cost of running a Laravel SaaS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Runbook
&lt;/h2&gt;

&lt;p&gt;Here is the sequence we recommend, in order, with the queue cutover treated as the delicate part it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Provision the app and worker servers
&lt;/h3&gt;

&lt;p&gt;Connect a cloud provider to Deploynix (DigitalOcean, Vultr, Linode, Hetzner, AWS, or a custom server) and provision two servers: an &lt;strong&gt;app server&lt;/strong&gt; (Nginx, PHP-FPM, your PHP version) and a &lt;strong&gt;worker server&lt;/strong&gt; for queues and scheduled tasks. Small teams can run everything on one box; separating web and worker traffic means a runaway job cannot starve your HTTP requests. If you are staying near RDS, provision on AWS in the same region.&lt;/p&gt;

&lt;p&gt;Connect your GitHub, GitLab, or Bitbucket repository, and translate your &lt;code&gt;vapor.yml&lt;/code&gt; build and deploy steps into the deploy configuration and deploy hooks. The mapping is almost one-to-one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Deploy script (runs on each release)&lt;/span&gt;
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan config:cache
php artisan event:cache
php artisan queue:restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploys are zero-downtime by default: each push builds a new release directory, and a symlink swap makes it live, with one-click rollback to the previous release. This replaces Vapor's deploy pipeline, including the rollback story.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Move environment variables
&lt;/h3&gt;

&lt;p&gt;Take the file from &lt;code&gt;vapor env:pull&lt;/code&gt; and load it into your server's environment through the dashboard. The changes are small and predictable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;APP_ENV&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="py"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;

&lt;span class="py"&gt;DB_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mysql&lt;/span&gt;
&lt;span class="py"&gt;DB_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;acme-production.xxxx.eu-west-1.rds.amazonaws.com  # RDS stays, for now&lt;/span&gt;

&lt;span class="py"&gt;QUEUE_CONNECTION&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;database   # was sqs&lt;/span&gt;
&lt;span class="py"&gt;CACHE_STORE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;redis           # Valkey on the worker/cache server, was ElastiCache&lt;/span&gt;
&lt;span class="py"&gt;SESSION_DRIVER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;database     # survives the cutover across both platforms&lt;/span&gt;

&lt;span class="py"&gt;FILESYSTEM_DISK&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;s3          # unchanged, S3 stays S3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the &lt;code&gt;SQS_*&lt;/code&gt; and &lt;code&gt;VAPOR_*&lt;/code&gt; variables only after the cutover is complete. During the parallel window, both platforms run with their own queue connection, which is exactly what makes the next step safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Cut over the queues without losing a job
&lt;/h3&gt;

&lt;p&gt;This is the step people worry about, and the strategy is simple: &lt;strong&gt;new jobs go to the new queue, old jobs drain from the old one, and for a while both sets of workers run.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the new servers, configure queue workers through the Deploynix UI: connection, queue names (&lt;code&gt;default&lt;/code&gt;, &lt;code&gt;notifications&lt;/code&gt;, matching your &lt;code&gt;vapor.yml&lt;/code&gt;), process count, timeout, and retry settings. Deploynix writes the Supervisor configuration for you, so workers restart on failure and on deploy.&lt;/li&gt;
&lt;li&gt;Deploy the app to the new servers with &lt;code&gt;QUEUE_CONNECTION=database&lt;/code&gt; (or &lt;code&gt;redis&lt;/code&gt;). At this point the new stack is dispatching to and consuming from its own queues.&lt;/li&gt;
&lt;li&gt;Leave Vapor's queue workers running. They keep consuming whatever is still in SQS, including delayed and released jobs.&lt;/li&gt;
&lt;li&gt;Watch the SQS console until &lt;code&gt;ApproximateNumberOfMessages&lt;/code&gt; and the not-visible count both sit at zero, and stay there. Remember delayed jobs: if you dispatch jobs with long delays, the drain window must be at least as long as your longest delay.&lt;/li&gt;
&lt;li&gt;Only then disable Vapor's queue processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mind your &lt;code&gt;failed_jobs&lt;/code&gt; table during the window: jobs can fail on either platform, and you want &lt;code&gt;queue:retry&lt;/code&gt; run from the side that owns the job. If you have not already built retry and timeout discipline into your jobs, do it before the migration, not after; our &lt;a href="https://deploynix.io/blog/laravel-queues-deep-dive-connections-workers-and-retry-strategies-on-deploynix" rel="noopener noreferrer"&gt;queues deep dive on connections, workers, and retry strategies&lt;/a&gt; covers the settings that matter, and &lt;a href="https://deploynix.io/blog/debugging-laravel-queue-failures-in-production-without-losing-messages" rel="noopener noreferrer"&gt;debugging Laravel queue failures without losing messages&lt;/a&gt; is the companion piece for when something does go sideways mid-cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Move the scheduler
&lt;/h3&gt;

&lt;p&gt;On Vapor, &lt;code&gt;scheduler: true&lt;/code&gt; ran your schedule through a CLI Lambda. On the worker server, it is one cron entry, configured through the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; php /home/deploynix/app/current/artisan schedule:run &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caution for the parallel window: if the scheduler runs on both platforms simultaneously, tasks fire twice. Use &lt;code&gt;-&amp;gt;onOneServer()&lt;/code&gt; with a shared cache store, or simpler, disable Vapor's scheduler at the moment you enable cron. For most apps a sixty-second gap in scheduling is harmless; double execution often is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: DNS cutover and the parallel window
&lt;/h3&gt;

&lt;p&gt;Drop your DNS TTL to 60 seconds a day before the cutover. Issue the SSL certificate for your domain on the new server (Deploynix handles Let's Encrypt issuance and renewal for free), and verify the new stack end to end using a hosts-file override or a temporary subdomain: login, checkout, webhooks, file uploads, a full queue round-trip.&lt;/p&gt;

&lt;p&gt;Then switch DNS from Vapor's CloudFront distribution to the new server (or to Cloudflare proxying the new server, which replaces CloudFront's CDN role). Because sessions are in the database and both stacks talk to the same RDS instance, users whose DNS resolves to either side during propagation get a consistent experience.&lt;/p&gt;

&lt;p&gt;Keep Vapor fully deployed and warm for at least a week. That is your rollback: if anything is wrong, point DNS back and you are on the old stack within minutes. Rollback for bad deploys on the new stack is separate and faster: one click back to the previous release. Only delete the Vapor environments when the new stack has survived a full weekly cycle, including your heaviest scheduled jobs.&lt;/p&gt;

&lt;p&gt;If parts of this runbook feel familiar, it is because the shape is the same for any platform exit; we walked through the PaaS variant in &lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt;migrating Laravel from Railway, Render, or &lt;/a&gt;&lt;a href="http://Fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;&lt;a href="https://deploynix.io/blog/migrating-laravel-from-railway-render-or-flyio-to-a-vps" rel="noopener noreferrer"&gt; to a VPS&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do You Actually Get Back?
&lt;/h2&gt;

&lt;p&gt;The cost savings get the headlines, but talk to teams six months after this migration and they lead with the capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A persistent local disk.&lt;/strong&gt; Temporary files, on-disk caches, generated exports that live for an hour before download: all trivially possible again. You stop paying S3 round-trip latency for scratch work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running jobs.&lt;/strong&gt; The 15-minute ceiling is gone. A nightly report that takes 40 minutes is just a queue job with &lt;code&gt;$timeout = 3600&lt;/code&gt; and a worker that is allowed to take its time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time WebSockets on the same box.&lt;/strong&gt; This one is binary: Vapor cannot host persistent socket connections, so Reverb is off the table there and you are pushed to Pusher or Ably as a paid external dependency. On a Deploynix server, Reverb runs as a daemon under Supervisor next to your app, and broadcasting works the way the Laravel docs describe, with no per-message pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-class queue tooling.&lt;/strong&gt; SQS through Vapor works, but you give up Laravel Horizon, which requires Redis. On a server with Valkey as your queue connection, Horizon runs as a daemon and gives you per-queue throughput, wait times, and failed-job retries in a dashboard your whole team can read. For queue-heavy apps, this alone changes how quickly you diagnose problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boring, predictable bills.&lt;/strong&gt; Two fixed-price servers cost the same in your busiest month and your quietest one. Finance can forecast it. Nobody audits Cost Explorer looking for the line item that doubled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct debuggability.&lt;/strong&gt; SSH in, &lt;code&gt;tail -f&lt;/code&gt; the log, run &lt;code&gt;php artisan tinker&lt;/code&gt; against production with appropriate care, watch &lt;code&gt;htop&lt;/code&gt; during an incident. CloudWatch is powerful, but during an outage, immediacy beats query languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Take On, and How It's Mitigated
&lt;/h2&gt;

&lt;p&gt;Honesty cuts both ways: leaving Vapor means someone is responsible for servers again. The real question is how much of that responsibility gets automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security and patching.&lt;/strong&gt; A public server needs a firewall, SSH hardening, fail2ban, and regular updates. Deploynix provisions servers hardened by default (key-only SSH, UFW configured, automatic security updates) so the baseline does not depend on you remembering it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups.&lt;/strong&gt; On Vapor, RDS snapshots were your safety net; if your database eventually moves off RDS, backups become your problem. Automated database backups to S3-compatible storage, on a schedule, with retention, are built in. Test a restore once a quarter regardless of platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring and capacity.&lt;/strong&gt; Lambda scaled silently; a server has finite CPU, RAM, and disk. Built-in monitoring with alerts covers the basics (load, memory, disk, service health), so you hear about a filling disk days before it becomes an incident. And when you genuinely outgrow one app server, you add a second behind a Deploynix-provisioned load balancer. That is a scaling step, not an emergency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The elasticity trade.&lt;/strong&gt; This is the one real loss. If your traffic can spike 50x in a minute, fixed servers need headroom or a CDN absorbing the burst, and neither is as effortless as Lambda. Be honest about whether that describes your traffic. For most steady-state SaaS apps, it does not, and provisioning 3x headroom still costs a fraction of the serverless equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: What Does Steady Load Cost on Each?
&lt;/h2&gt;

&lt;p&gt;Numbers make this concrete. Take a representative mid-size Laravel SaaS at steady load: about 5 million HTTP requests a month averaging 250ms at 1GB memory, 2 million queue jobs a month averaging 2 seconds, an RDS MySQL instance, a small Redis cache, CDN in front, and functions inside a VPC (which means a NAT gateway). Approximate monthly costs at current us-east/eu-west pricing:&lt;/p&gt;

&lt;p&gt;Line item&lt;/p&gt;

&lt;p&gt;Vapor stack&lt;/p&gt;

&lt;p&gt;Two-server Deploynix stack&lt;/p&gt;

&lt;p&gt;HTTP compute&lt;/p&gt;

&lt;p&gt;Lambda: ~$21 + API Gateway: ~$5&lt;/p&gt;

&lt;p&gt;App server (4 vCPU / 8GB, DigitalOcean): $24&lt;/p&gt;

&lt;p&gt;Queue compute&lt;/p&gt;

&lt;p&gt;Lambda workers: ~$66 + SQS: ~$1&lt;/p&gt;

&lt;p&gt;Worker server (2 vCPU / 4GB): $18&lt;/p&gt;

&lt;p&gt;Warming (10 instances)&lt;/p&gt;

&lt;p&gt;~$8&lt;/p&gt;

&lt;p&gt;n/a (always warm)&lt;/p&gt;

&lt;p&gt;NAT gateway&lt;/p&gt;

&lt;p&gt;~$42 (hours + data processing)&lt;/p&gt;

&lt;p&gt;n/a&lt;/p&gt;

&lt;p&gt;Database&lt;/p&gt;

&lt;p&gt;RDS db.t4g.medium + storage: ~$58&lt;/p&gt;

&lt;p&gt;Same RDS instance: ~$58 (unchanged)&lt;/p&gt;

&lt;p&gt;Cache&lt;/p&gt;

&lt;p&gt;ElastiCache t4g.micro: ~$12&lt;/p&gt;

&lt;p&gt;Valkey on worker server: $0&lt;/p&gt;

&lt;p&gt;CDN&lt;/p&gt;

&lt;p&gt;CloudFront: ~$10&lt;/p&gt;

&lt;p&gt;Cloudflare free tier: $0&lt;/p&gt;

&lt;p&gt;Logging&lt;/p&gt;

&lt;p&gt;CloudWatch: ~$15&lt;/p&gt;

&lt;p&gt;Local logs: $0&lt;/p&gt;

&lt;p&gt;Platform fee&lt;/p&gt;

&lt;p&gt;Vapor: $39/project&lt;/p&gt;

&lt;p&gt;Deploynix plan&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~$277 + traffic growth&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;~$100 + flat&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two observations. First, the compute you actually think about (Lambda HTTP) is not the expensive part; the queue workers, NAT gateway, warming, and observability plumbing around it are. Second, the server column barely moves as traffic grows: the same $24 app server that handles 5 million requests a month handles 15 million, while every Lambda line item scales linearly. At steady load, that roughly $175 monthly gap is the serverless premium, and it widens every month your traffic grows. Squeeze further by moving the database off RDS later, or by provisioning on Hetzner, and the gap gets embarrassing; the fuller teardown lives in &lt;a href="https://deploynix.io/blog/cloud-repatriation-for-laravel-from-aws-to-a-10-vps" rel="noopener noreferrer"&gt;cloud repatriation for Laravel: from AWS to a $10 VPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your numbers will differ. Run yours before deciding; the audit in this post gives you every input you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I keep RDS and still migrate off Vapor?
&lt;/h3&gt;

&lt;p&gt;Yes, and we recommend it. Provision your app server on AWS in the same region as your RDS instance, allow its IP in the RDS security group, and point &lt;code&gt;DB_HOST&lt;/code&gt; at the endpoint. Latency stays in single-digit milliseconds, and you have decoupled the risky database move from the compute migration entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does the migration take?
&lt;/h3&gt;

&lt;p&gt;For a typical app: an afternoon for the audit, a day to provision and configure the new stack, and a one-to-two week parallel window before you decommission Vapor. The active cutover itself (queue switch plus DNS) is an evening. The parallel window is not wasted time; it is your rollback insurance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will I lose queued jobs during the cutover?
&lt;/h3&gt;

&lt;p&gt;Not if you drain rather than switch. New servers dispatch to the new queue connection while Vapor's workers keep consuming SQS until it is empty, including delayed jobs. Both worker fleets run simultaneously during the window. The only way to lose jobs is to turn off SQS consumption before the backlog and delay horizon are clear, so don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Octane? We used it on Vapor for performance.
&lt;/h3&gt;

&lt;p&gt;Octane runs at least as well on a server, arguably better, because workers persist indefinitely instead of living inside Lambda's lifecycle. Deploynix supports Octane with FrankenPHP, Swoole, or RoadRunner running as a daemon under Supervisor. Plenty of teams migrate to plain PHP-FPM first and adopt Octane later once they have baseline numbers to compare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this a full "leave AWS" move?
&lt;/h3&gt;

&lt;p&gt;No, and framing it that way leads to bad decisions. Only about 5% of organizations plan full repatriation (&lt;a href="https://advancedhosting.com/cloud-repatriation/" rel="noopener noreferrer"&gt;Advanced Hosting&lt;/a&gt;); the sensible pattern is workload by workload. In this migration, S3 stays, RDS can stay, and only the compute layer (the part with the worst steady-load economics) moves to servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;Vapor earned its place in the Laravel ecosystem, and if your traffic is genuinely spiky or your ops appetite is genuinely zero, staying is defensible. But if your dashboard shows the same steady curve every week, you are renting elasticity you do not use, and the exit is more mechanical than it looks: map the concepts, audit the &lt;code&gt;vapor.yml&lt;/code&gt;, drain the queues, flip DNS, keep the old stack warm for a week.&lt;/p&gt;

&lt;p&gt;The single next step: run the audit. Pull your &lt;code&gt;vapor.yml&lt;/code&gt; and your last three months of AWS invoices, fill in the cost table above with your own numbers, and see where you land. If the gap looks like ours, provision a test server on Deploynix, deploy a staging copy of your app, and put real traffic through it before you commit to anything. The numbers will tell you whether this migration is worth your weekend. In our experience, for steady-traffic Laravel apps, they almost always do.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>vapor</category>
      <category>serverless</category>
      <category>migration</category>
    </item>
  </channel>
</rss>
