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

> Learn how to set up a multi-instance HA deployment with Postgres and advisory locks for Instatic. Ensure scheduled tasks run once without external services.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: how-to-guide
- Published: 2026-07-30

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/publishScheduler.ts), lines 94-99) and plugin scheduler ([`server/plugins/scheduler.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`.

```bash
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:

```bash
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:

```yaml

# 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:

```bash
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`](https://github.com/CoreBunch/Instatic/blob/main/server/db/advisoryLock.ts) file exports the core coordination logic:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.