Back to Blog

Deploying a Zero Sync Engine on Coolify

A Step-by-Step Guide to Self-Hosting Your Sync Engine
Aug 17, 2025
#Local First#Zero Sync#Rocicorp#Coolify#Self-Hosting

Deploying a Zero Sync Engine on Coolify

Local-first development is a great concept, but getting the sync engine right can be a challenge. After hitting some walls with other solutions, I moved on to Zero Sync. It feels more mature and is easier to implement, even in its alpha stage.

Getting it running locally is one thing, but a real test of sync requires a remote server. Here’s how I deployed a self-hosted Zero Sync engine to my VPS using Coolify.

Docker Options (For Self-Hosted PostgreSQL) If you are running PostgreSQL as a Docker container yourself (for example, on Coolify), add the following to your service’s Custom Docker Options:

--tmpfs /run:rw,noexec,nosuid,size=65536k

This mounts a temporary, in-memory file system that can improve performance for certain database operations.

Prepare Your PostgreSQL Database

Your PostgreSQL instance needs two specific changes to work with Zero Sync. You’ll need to edit your postgresql.conf file or apply these settings through your database provider’s control panel.

listen_addresses = '*'
wal_level = logical

The listen_addresses setting allows the database to accept connections from other services, like our sync engine. The wal_level setting enables the logical replication feature that Zero Sync depends on to function.

You must restart your PostgreSQL server for these changes to take effect.

Deploy the Zero Cache Service

In your Coolify project, add a new resource and select Docker Compose. This is where we’ll define the sync service itself.

Paste this configuration into the editor:

services:
  zero_cache:
    image: 'rocicorp/zero:latest'
    environment:
      - 'ZERO_UPSTREAM_DB=${DATABASE_URL}'
      - 'ZERO_CVR_DB=${DATABASE_URL}'
      - 'ZERO_CHANGE_DB=${DATABASE_URL}'
      - 'ZERO_AUTH_JWKS_URL=${ZERO_AUTH_JWKS_URL}'
      - 'ZERO_UPSTREAM_MAX_CONNS=4'
      - 'ZERO_CVR_MAX_CONNS=20'
      - 'ZERO_CHANGE_MAX_CONNS=1'
      - ZERO_REPLICA_FILE=/zero_data/zchat_replica.db
      - ZERO_PORT=4848
    volumes:
      - 'replica:/zero_data'

volumes:
  replica:

Next, go to the Environment Variables tab and add your secrets. The DATABASE_URL is for your PostgreSQL instance, and the ZERO_AUTH_JWKS_URL points to your authentication provider’s JSON Web Key Set.

Configure the Domain

To expose the service, go to the General tab for your zero_cache service in Coolify.

Enter the public URL you want to use in the Domains field, like https://sync.yourdomain.com:4848. Then, in your DNS provider, create an A record for that subdomain pointing to your Coolify server’s IP address.

Click Save and Deploy.

Define and Deploy Permissions

By default, Zero Sync denies all database operations. You have to explicitly grant permissions by deploying a schema file.

Create a schema.ts file in your local project to define your data tables and access rules. This example creates a simple tasks table and allows anyone to do anything with it.

// src/schema.ts
import { table, string, boolean, createSchema, definePermissions, ANYONE_CAN_DO_ANYTHING } from '@rocicorp/zero';

type AuthData = { userID: string; };

const tasks = table('tasks').columns({
    id: string(),
    title: string(),
    completed: boolean(),
}).primaryKey("id");

export const schema = createSchema({ tables: [tasks] });
export type Schema = typeof schema;

export const permissions = definePermissions<AuthData, typeof schema>(schema, () => ({
    tasks: ANYONE_CAN_DO_ANYTHING
}));

Deploy these rules from your terminal using the Zero Sync CLI.

npx zero-deploy-permissions -p src/schema.ts --upstream-db YOUR_POSTGRES_DATABASE_URL

Connect Your Front-End App

Finally, connect your Svelte app to the new sync engine. In your project’s .env file, add the public URL you configured.

PUBLIC_SERVER=https://sync.yourdomain.com

Then, use that environment variable to initialize the ZeroClient in your application code.

I opted to extend the svelte bindings to add custom methods for common operations and simplify the API.

// src/lib/z.svelte.ts
import { PUBLIC_SERVER } from '$env/static/public';
import { Z } from 'zero-svelte';
import { schema, type Schema } from '../schema';
import { nanoid } from 'nanoid';

export function get_z_options() {
    return {
        userID: 'anon',
        server: PUBLIC_SERVER,
        schema,
    } as const;
}

class Zero extends Z<Schema> {
    constructor() {
        super(get_z_options());
    }

    toggle(id: string, completed: boolean) {
        this.current.mutate.tasks.update({ id, completed });
    }

    update(id: string, title: string) {
        this.current.mutate.tasks.update({ id, title });
    }

    delete(id: string) {
        this.current.mutate.tasks.delete({ id });
    }

    create(title: string) {
        console.log('create', title);
        this.current.mutate.tasks.insert({ id: nanoid(), title, completed: false });
    }
}

export const z = new Zero(); 

With this setup, you have a solid foundation for a real-time, local-first application powered by your own self-hosted sync engine.

Chat With
Michael Support
Send a msg to start the chat. You can ask the bot anything about me.