How to Set Up a Multi-Instance HA Deployment with Postgres and Advisory Locks for Instatic

Instatic uses PostgreSQL advisory locks to elect a single leader instance among multiple replicas, ensuring scheduled tasks execute exactly once per interval without external coordination services.

Instatic is architected to scale horizontally behind a load balancer, but running multiple instances risks duplicate execution of scheduled publishes and plugin tasks. This guide explains how to configure a multi-instance HA deployment with Postgres and advisory locks for Instatic, leveraging the native leader-election primitive in server/db/advisoryLock.ts to coordinate work across replicas using database-native primitives.

How Advisory Lock Leader Election Works

The high-availability mechanism centers on the withSchedulerLeaderLock function in server/db/advisoryLock.ts (lines 82-95). When multiple instances start their recurring tick loops, each attempts to acquire a PostgreSQL advisory lock via pg_try_advisory_lock. The first instance to succeed becomes the leader for that tick interval and executes the scheduled function; all other instances skip the work. When the tick completes or errors, the lock releases automatically, allowing another instance to take leadership in the next interval.

The Lock Acquisition Flow

The coordination follows a deterministic sequence:

  1. Scheduler ticks invoke the lock primitive – Both the publish scheduler (server/publish/publishScheduler.ts, lines 94-99) and plugin scheduler (server/plugins/scheduler.ts, lines 141-146) call withSchedulerLeaderLock(db, LOCK_KEY, LOG_PREFIX, fn) at each interval.

  2. Attempt lock acquisition – The tryAcquireLeader function (lines 42-55) executes select pg_try_advisory_lock(lockKey). If the query returns true, the instance receives a 'pg-advisory' token and becomes leader.

  3. Execute scheduled work – Upon receiving the token, the supplied async function fn runs. Other instances receive null and exit immediately.

  4. Release on completion – After fn finishes, releaseLeader (lines 71-74) runs select pg_advisory_unlock(lockKey), freeing the lock for the next interval.

  5. SQLite fallback – In SQLite mode, the function returns 'sqlite-leader' (lines 53-55) since SQLite supports only single-process access, rendering the lock a no-op.

Each scheduler uses a distinct lock key—0xdeadbeef for publishing and 0xcafebabe for plugins—ensuring the two loops do not contend with each other.

Step-by-Step HA Deployment Guide

1. Provision PostgreSQL

Configure a PostgreSQL database and set the DATABASE_URL environment variable to use the Postgres adapter. Instatic automatically detects the adapter when the URL scheme is postgres.

export DATABASE_URL="postgres://user:pass@db-host:5432/instatic"

2. Build the Container Image

The repository includes a production-ready Dockerfile. Build the image locally:

docker build -t instatic:latest .

3. Run Multiple Replicas

Deploy multiple containers behind a reverse proxy or load balancer. The following Docker Compose configuration scales to three instances:


# docker-compose.yml

version: "3.9"
services:
  instatic:
    image: instatic:latest
    environment:
      - DATABASE_URL=postgres://instatic:secret@db:5432/instatic
    deploy:
      replicas: 3
      restart_policy:
        condition: on-failure
    ports:
      - "3000:3000"

Start the cluster with:

docker compose up -d

4. Verify Leader Election

Check the logs to confirm only one instance acquires the lock per tick. You should see exactly one entry like:


[publish-scheduler] acquired advisory lock, running scheduled publish…

If multiple instances show this message simultaneously, verify that all containers connect to the same PostgreSQL database and that the DATABASE_URL uses the postgres scheme.

5. Customize Lock Keys for New Jobs

When adding custom recurring tasks, assign a unique integer lock key and wrap the execution logic with withSchedulerLeaderLock. This prevents new jobs from interfering with existing publish or plugin schedules.

Implementation Details and Code Examples

Core Advisory Lock Primitive

The server/db/advisoryLock.ts file exports the core coordination logic:

// server/db/advisoryLock.ts
export async function tryAcquireLeader(db: DbClient, lockKey: number): Promise<LeaderToken> {
  try {
    const { rows } = await db<{ got: boolean }>`
      select pg_try_advisory_lock(${lockKey}) as got
    `;
    return rows[0]?.got ? 'pg-advisory' : null;
  } catch {
    // SQLite fallback – always leader
    return 'sqlite-leader';
  }
}

Publish Scheduler Configuration

The publish scheduler uses lock key 0xdeadbeef to coordinate site builds:

// server/publish/publishScheduler.ts
import { withSchedulerLeaderLock } from '../db/advisoryLock';
import { DB } from '../db/client';

const ADVISORY_LOCK_KEY = 0xdeadbeef;          // unique per scheduler
const LOG_PREFIX = '[publish-scheduler]';

export async function runPublishTick(db: DB) {
  await withSchedulerLeaderLock(db, ADVISORY_LOCK_KEY, LOG_PREFIX, async () => {
    // …publish logic here (only runs on the leader)…
    await publishAllSites();
  });
}

Plugin Scheduler Configuration

The plugin scheduler employs a distinct lock key (0xcafebabe) to run periodic tasks independently:

// server/plugins/scheduler.ts
import { withSchedulerLeaderLock } from '../db/advisoryLock';
import { DB } from '../db/client';

const ADVISORY_LOCK_KEY = 0xcafebabe;          // distinct from publish lock
const LOG_PREFIX = '[plugin-scheduler]';

export async function runPluginTick(db: DB) {
  await withSchedulerLeaderLock(db, ADVISORY_LOCK_KEY, LOG_PREFIX, async () => {
    // …run periodic plugin tasks…
    await tickAllPlugins();
  });
}

Summary

  • Native PostgreSQL primitives – Instatic uses pg_try_advisory_lock and pg_advisory_unlock via the withSchedulerLeaderLock wrapper in server/db/advisoryLock.ts to coordinate multiple instances.
  • Automatic failover – If the leader instance crashes or errors, the advisory lock releases automatically, allowing another replica to acquire leadership in the next tick without manual intervention.
  • Isolated schedulers – The publish scheduler (0xdeadbeef) and plugin scheduler (0xcafebabe) use separate lock keys, enabling parallel execution of different job types.
  • SQLite compatibility – Single-instance SQLite deployments bypass the locking logic via a sentinel token, while PostgreSQL enables true multi-instance HA.
  • No external dependencies – Unlike Redis or ZooKeeper-based coordination, this approach requires only the PostgreSQL database already used for application data.

Frequently Asked Questions

What happens if the leader instance crashes mid-execution?

If the leader crashes while holding the advisory lock, PostgreSQL automatically releases the lock when the database session terminates. The next instance to attempt the lock acquisition in the subsequent tick interval will succeed and become the new leader. This ensures automatic failover without requiring health-check daemons or external monitoring.

Can I use SQLite for multi-instance deployments?

No. SQLite mode in Instatic is designed for single-process deployments only. When DATABASE_URL points to a SQLite file, server/db/advisoryLock.ts returns a 'sqlite-leader' sentinel (lines 53-55) that bypasses the locking logic. Running multiple instances against SQLite would result in duplicate task execution and database corruption. Use PostgreSQL for any multi-instance HA deployment with advisory locks for Instatic.

How do I add custom scheduled jobs with advisory locks?

Define a unique 32-bit integer lock key constant (avoiding 0xdeadbeef and 0xcafebabe used by core schedulers) and wrap your job logic with withSchedulerLeaderLock imported from server/db/advisoryLock.ts. Pass your database client, the unique lock key, a log prefix for observability, and your async job function. This ensures your custom tasks participate in the same leader-election protocol as the built-in schedulers.

Do I need external tools like Redis or ZooKeeper for coordination?

No. Instatic’s advisory-lock implementation eliminates the need for external coordination services. Because the mechanism relies on PostgreSQL’s native pg_try_advisory_lock function, you achieve distributed mutual exclusion using only the database connection already required for application state. This reduces infrastructure complexity and potential points of failure in your multi-instance HA deployment.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →