log in
consulting hosting industries the daily tools about contact

Adding a Read Replica Before Your Single VM Catches Fire

When your single-VM Postgres finally hits its ceiling, a read replica buys you time. Here's the actual runbook — migrations, Laravel config, and the lag trap nobody warns you about.

The moment you've been ignoring finally arrives: pg_stat_activity is packed, your query logs are full of sequential scans, and the client is asking why the dashboard takes eight seconds to load. You don't need a full rewrite. You need to stop sending read traffic to the same machine that's handling writes. A read replica is the right next move — but the path from single-VM to primary/replica has a few traps that will absolutely bite you if you rush it.

This is the runbook I follow. It's built around PostgreSQL streaming replication and Laravel's built-in read/write connection splitting. I'm assuming you're on a single Ubuntu/Debian VM with Postgres 14+, running Laravel 10+, and you have enough budget to spin up a second VM.

Step 0: Know What You're Actually Doing

Streaming replication in Postgres is physical replication — the replica gets a byte-for-byte copy of the primary's WAL (write-ahead log) and replays it continuously. It's not logical replication, it's not a backup snapshot (though it makes a great backup), and it's not magic. The replica is read-only. Any query that writes must go to the primary. Laravel's database layer knows how to route this — but only if you configure it correctly and only if your application code respects the split.

Step 1: Prepare the Primary

On your existing VM (the primary), you need to confirm replication is enabled and create a dedicated replication user. Most managed configs already have wal_level = replica but on a self-compiled or older install, check:

psql -U postgres -c "SHOW wal_level;"

If it says minimal, you need to change it in postgresql.conf and restart. That restart will cause downtime — do it during a maintenance window.

Create the replication user:

CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'your-strong-password';

Then in pg_hba.conf, add an entry that allows the replica VM's IP to connect:

host    replication     replicator      10.0.1.52/32    scram-sha-256

Reload: pg_ctlcluster 14 main reload (adjust for your Postgres version and cluster name).

Also bump these in postgresql.conf if they're at defaults — they matter for keeping the replica from falling too far behind:

max_wal_senders = 5
wal_keep_size = 512   # MB, Postgres 13+

Restart if you changed max_wal_senders.

Step 2: Base Backup to the Replica VM

Spin up the new VM. Install the same major version of Postgres. Stop the cluster if it auto-started (pg_ctlcluster 14 main stop), and wipe the data directory:

rm -rf /var/lib/postgresql/14/main/*

Now run pg_basebackup from the replica to pull a base backup from the primary:

pg_basebackup \
  -h 10.0.1.51 \
  -U replicator \
  -D /var/lib/postgresql/14/main \
  -P \
  -Xs \
  -R

The -R flag is the key one. It writes a standby.signal file and pre-populates postgresql.auto.conf with the connection info for streaming replication. When you start Postgres on the replica, it will automatically enter standby mode and start streaming WAL from the primary.

For a 20GB database this base backup might take 5-10 minutes. For a 200GB database, plan accordingly — the primary keeps serving traffic the whole time, which is fine.

Start the replica:

pg_ctlcluster 14 main start

Verify on the primary:

SELECT client_addr, state, sent_lsn, write_lsn, replay_lsn
FROM pg_stat_replication;

You want to see your replica's IP with state = streaming. If it shows startup and never moves, check the replica's logs — usually it's a pg_hba.conf miss or a password problem.

Step 3: Laravel Read/Write Configuration

Laravel has first-class support for read/write splitting in config/database.php. Here's what the Postgres connection block looks like after the change:

'pgsql' => [
    'driver' => 'pgsql',
    'read' => [
        'host' => [
            env('DB_READ_HOST', env('DB_HOST', '127.0.0.1')),
        ],
    ],
    'write' => [
        'host' => [
            env('DB_HOST', '127.0.0.1'),
        ],
    ],
    'sticky' => true,
    'port' => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => 'utf8',
    'prefix' => '',
    'prefix_indexes' => true,
    'search_path' => 'public',
    'sslmode' => 'prefer',
],

Then in .env:

DB_HOST=10.0.1.51
DB_READ_HOST=10.0.1.52

With this config, Eloquent and the Query Builder send SELECT statements to the replica and everything else — INSERT, UPDATE, DELETE, DDL — to the primary. The sticky option is important and I'll explain why in a moment.

Step 4: Migrations — Do Not Skip This Part

Here's where people get burned. Laravel runs migrations using the DB facade, which by default will try to use the read connection for SELECT queries inside the migration. But the migration table itself, and any DDL you're running, must hit the primary.

Laravel handles migration execution on the write connection correctly — Schema:: calls and DB::statement() go to write — but if you have a migration that reads data to transform it, wrap it explicitly:

public function up(): void
{
    // DDL always goes to write connection — fine
    Schema::table('orders', function (Blueprint $table) {
        $table->string('status_normalized')->nullable();
    });

    // Data migration — force the write connection
    DB::connection('pgsql')->statement("
        UPDATE orders SET status_normalized = lower(trim(status))
    ");
}

Also: never run php artisan migrate while replication lag is high. If you add a column on the primary, then immediately read from the replica to check if it exists, the replica may not have replayed that WAL segment yet. Your migration might appear to succeed but leave the app in a broken state for a few seconds or minutes. I've seen this cause real incidents.

My rule: run migrations, then watch replay_lsn on the primary catch up to sent_lsn before you deploy the code that depends on the new schema.

The Lag Trap

This is the one that gets people. Streaming replication is asynchronous by default. The replica replays WAL as fast as it can, but it's always some amount behind the primary. On a lightly loaded system this might be 10-100ms. On a system doing heavy writes, it can be seconds.

The scenario that burns you: a user submits a form, the INSERT goes to the primary, then the redirect loads a page that does a SELECT — which goes to the replica — and the row isn't there yet. The user sees a blank page or a 404.

This is exactly what Laravel's sticky option addresses. With 'sticky' => true, if Laravel has issued any write query during the current request, all subsequent reads in that same request go to the write connection instead. This covers the form-submit-then-redirect case most of the time.

But sticky doesn't help across requests. If request A writes a row and request B (from the same user, milliseconds later) reads it, B might hit the replica and miss it.

For anything where you know you need immediate read-your-own-write consistency, bypass the replica explicitly:

$order = DB::connection('pgsql')
    ->table('orders')
    ->where('id', $orderId)
    ->first();

Or use a service class pattern where certain repositories always force the write connection for post-mutation reads. It's a bit of ceremony but it's honest about what you're asking for.

You can also monitor lag from the replica side:

SELECT
    now() - pg_last_xact_replay_timestamp() AS replication_lag;

I expose this in a /health endpoint and alert if it exceeds 30 seconds. A replica that's fallen more than a minute behind is a liability, not an asset.

When I'd Reach for This

I add a read replica when a client's single VM is CPU or I/O bound on reads — usually reporting queries, dashboard aggregations, or API endpoints that do expensive joins. It's the right move when you're getting 80-90% read traffic and you don't want to over-provision the primary for read workloads.

It's also worth doing purely as a warm standby even if you don't need the read capacity. If the primary VM dies, you can promote the replica in a few minutes. That's a lot of uptime for not much cost.

I wouldn't bother with this if the bottleneck is writes. Replication doesn't help you there — you need to look at connection pooling (PgBouncer), query optimization, or rethinking the schema. A read replica on a write-heavy system just adds complexity and lag surface area with no real benefit.

I also wouldn't set this up if the team can't commit to being careful about the lag trap. I've had to rip out read/write splitting on one project because developers kept introducing subtle bugs around eventual consistency and nobody wanted to think about it at code-review time. Sometimes the simpler architecture is the right one.

Closing

A read replica on a single-VM setup is one of the highest-leverage infrastructure moves you can make for a read-heavy Laravel app — maybe four hours of work to cut your primary's load in half. Just don't treat it as a transparent drop-in. Replication lag is real, sticky only saves you within a request, and your migration workflow needs to account for the split. Get those three things right and it's one of the most boring, reliable pieces of infrastructure you'll run.

Related

Need help shipping something like this? Get in touch.