# Self-Hosting Zero

To self-host Zero, you will need to deploy zero-cache, a Postgres database, your frontend, and your API server.

Zero-cache is made up of two main components:

1. One or more *view-syncers*: serving client queries using a SQLite replica.
2. One *replication-manager*: bridge between the Postgres replication stream and view-syncers.

These components have the following characteristics:

|                       | Replication Manager        | View Syncer          |
| --------------------- | -------------------------- | -------------------- |
| Owns replication slot | ✅                          | ❌                    |
| Serves client queries | ❌                          | ✅                    |
| Backs up replica      | ✅ (required in multi-node) | ❌                    |
| Restores from backup  | Optional                   | Required             |
| Subscribes to changes | N/A (produces)             | ✅                    |
| CVR management        | ❌                          | ✅                    |
| Number deployed       | 1                          | N (horizontal scale) |

You will also need to deploy a Postgres database, your frontend, and your API server for the [query](https://zero.rocicorp.dev/docs/queries#server-setup) and [mutate](https://zero.rocicorp.dev/docs/mutators#server-setup) endpoints.

Before setting up Postgres, read [Connecting to Postgres](https://zero.rocicorp.dev/docs/connecting-to-postgres) for provider-specific notes.

## Docker Images

The examples below use Docker Hub, but the Zero container image is available from:

* Docker Hub: `rocicorp/zero:1.8.0`
* GHCR: `ghcr.io/rocicorp/zero:1.8.0`

## Minimum Viable Strategy

The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far.

![](https://zero.rocicorp.dev/images/deployment/single-node.svg)

Here are equivalent single-node configurations for a few common deployment targets:

**Docker Compose**

```yaml
services:
  zero-cache:
    image: rocicorp/zero:1.8.0
    ports:
      - 4848:4848
    stop_grace_period: 10m
    environment:
      # Used for replication from postgres
      # This *must* be a direct connection (not via pgbouncer)
      ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero
      # Used for storing client view records
      # Use a pooler in production
      ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero
      # Used for storing recent replication log entries
      # Use a pooler in production
      ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero
      # Path to the SQLite replica
      ZERO_REPLICA_FILE: /data/replica.db
      # Password used to access the inspector and /statz
      ZERO_ADMIN_PASSWORD: pickanewpassword
      # URLs for your API /query and /mutate endpoints
      ZERO_QUERY_URL: https://api.example.com/api/zero/query
      ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate
      ZERO_ENABLE_CRUD_MUTATIONS: 'false'
    volumes:
      - zero-cache-data:/data
    healthcheck:
      test: curl -f http://localhost:4848/keepalive
      interval: 5s
      start_period: 10m

  upstream-db:
    image: postgres:18
    environment:
      POSTGRES_DB: zero
      POSTGRES_PASSWORD: pass
    ports:
      - 5432:5432
    command: postgres -c wal_level=logical
    healthcheck:
      test: pg_isready
      interval: 10s
```

**Fly.io**

```toml
app = "zero-cache"
primary_region = "iad"
kill_timeout = 300

[build]
  image = "rocicorp/zero:1.8.0"

[http_service]
  internal_port = 4848
  force_https = true
  auto_stop_machines = "off"
  min_machines_running = 1

[[http_service.checks]]
  protocol = "https"
  path = "/keepalive"
  interval = "5s"
  timeout = "5s"
  grace_period = "10m"

[mounts]
  source = "zero_data"
  destination = "/data"

[env]
  ZERO_UPSTREAM_DB = "postgresql://postgres:pass@db.internal:5432/zero"
  ZERO_CVR_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_CHANGE_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_ADMIN_PASSWORD = "pickanewpassword"
  ZERO_QUERY_URL = "https://api.example.com/api/zero/query"
  ZERO_MUTATE_URL = "https://api.example.com/api/zero/mutate"
  ZERO_ENABLE_CRUD_MUTATIONS = "false"
  ZERO_REPLICA_FILE = "/data/replica.db"
```

**SST**

```ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: 'zero',
      home: 'aws',
      removal:
        input?.stage === 'production' ? 'retain' : 'remove'
    }
  },
  async run() {
    const vpc = new sst.aws.Vpc('ZeroVpc')
    const cluster = new sst.aws.Cluster('ZeroCluster', {
      vpc
    })
    const efs = new sst.aws.Efs('ZeroReplicaFs', {vpc})

    new sst.aws.Service('ZeroCache', {
      cluster,
      image: 'rocicorp/zero:1.8.0',
      cpu: '1 vCPU',
      memory: '2 GB',
      volumes: [{efs, path: '/data'}],
      environment: {
        ZERO_UPSTREAM_DB:
          'postgresql://postgres:pass@postgres:5432/zero',
        ZERO_CVR_DB:
          'postgresql://postgres:pass@pgbouncer:5432/zero',
        ZERO_CHANGE_DB:
          'postgresql://postgres:pass@pgbouncer:5432/zero',
        ZERO_ADMIN_PASSWORD: 'pickanewpassword',
        ZERO_QUERY_URL:
          'https://api.example.com/api/zero/query',
        ZERO_MUTATE_URL:
          'https://api.example.com/api/zero/mutate',
        ZERO_ENABLE_CRUD_MUTATIONS: 'false',
        ZERO_REPLICA_FILE: '/data/replica.db'
      },
      health: {
        command: [
          'CMD-SHELL',
          'curl -f http://localhost:4848/keepalive || exit 1'
        ],
        startPeriod: '300 seconds'
      },
      loadBalancer: {
        public: true,
        ports: [{listen: '80/http', forward: '4848/http'}]
      },
      transform: {
        service: {
          healthCheckGracePeriodSeconds: 600
        },
        target: {
          healthCheck: {
            enabled: true,
            path: '/keepalive',
            protocol: 'HTTP',
            interval: 5,
            timeout: 3,
            healthyThreshold: 2
          }
        }
      }
    })
  }
})
```

**Kubernetes**

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: zero-cache
spec:
  replicas: 1
  selector:
    matchLabels:
      app: zero-cache
  template:
    metadata:
      labels:
        app: zero-cache
    spec:
      terminationGracePeriodSeconds: 600
      containers:
        - name: zero-cache
          image: rocicorp/zero:1.8.0
          ports:
            - name: http
              containerPort: 4848
          env:
            - name: ZERO_UPSTREAM_DB
              value: postgresql://postgres:pass@postgres:5432/zero
            - name: ZERO_CVR_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_CHANGE_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_ADMIN_PASSWORD
              value: pickanewpassword
            - name: ZERO_QUERY_URL
              value: https://api.example.com/api/zero/query
            - name: ZERO_MUTATE_URL
              value: https://api.example.com/api/zero/mutate
            - name: ZERO_ENABLE_CRUD_MUTATIONS
              value: 'false'
            - name: ZERO_REPLICA_FILE
              value: /data/replica.db
          lifecycle:
            preStop:
              exec:
                command: ['sh', '-c', 'sleep 10']
          volumeMounts:
            - name: data
              mountPath: /data
          startupProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
            failureThreshold: 120
          readinessProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /keepalive
              port: http
            periodSeconds: 10
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: zero-cache-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: zero-cache-data
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 20Gi
```

These snippets only show the zero-cache side of the deployment. The API behind `ZERO_QUERY_URL` and `ZERO_MUTATE_URL` can live anywhere zero-cache can reach.

## Maximal Strategy

Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability.

![](https://zero.rocicorp.dev/images/deployment/multi-node.svg)

Here are equivalent multi-node configurations for the same topology on a few common deployment targets:

**Docker Compose**

```yaml
services:
  replication-manager:
    image: rocicorp/zero:1.8.0
    # Do not expose the RM to the public internet - only view-syncers
    expose:
      - 4849
    stop_grace_period: 10m
    depends_on:
      upstream-db:
        condition: service_healthy
    environment:
      ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_REPLICA_FILE: /data/replica.db
      ZERO_ADMIN_PASSWORD: pickanewpassword
      ZERO_NUM_SYNC_WORKERS: 0
      ZERO_LITESTREAM_BACKUP_URL: s3://acme-zero-backups/v1
    volumes:
      - replication-manager-data:/data
    healthcheck:
      test: curl -f http://localhost:4849/keepalive
      interval: 5s
      start_period: 10m

  view-syncer:
    image: rocicorp/zero:1.8.0
    ports:
      - 4848:4848
    stop_grace_period: 10m
    depends_on:
      replication-manager:
        condition: service_healthy
    environment:
      ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero
      ZERO_REPLICA_FILE: /data/replica.db
      ZERO_ADMIN_PASSWORD: pickanewpassword
      ZERO_QUERY_URL: https://api.example.com/api/zero/query
      ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate
      ZERO_ENABLE_CRUD_MUTATIONS: 'false'
      ZERO_CHANGE_STREAMER_URI: ws://replication-manager:4849/
    volumes:
      - view-syncer-data:/data
    healthcheck:
      test: curl -f http://localhost:4848/keepalive
      interval: 5s
      start_period: 10m

  upstream-db:
    image: postgres:18
    environment:
      POSTGRES_DB: zero
      POSTGRES_PASSWORD: pass
    ports:
      - 5432:5432
    command: postgres -c wal_level=logical
    healthcheck:
      test: pg_isready
      interval: 10s
```

**Fly.io**

```toml
# replication-manager/fly.toml
app = "zero-replication-manager"
primary_region = "iad"
kill_timeout = 300

[build]
  image = "rocicorp/zero:1.8.0"

# Do not add [http_service] or [[services]] to this app. The
# replication-manager serves Zero's internal replication protocol and should
# only be reachable over Fly private networking at:
#   ws://zero-replication-manager.internal:4849/
#
# Since this app does not have [http_service], use a top-level Machine check.

[checks]
  [checks.replication_manager]
    type = "http"
    port = 4849
    path = "/"
    interval = "5s"
    timeout = "5s"
    grace_period = "10m"

[mounts]
  source = "replication_data"
  destination = "/data"

[env]
  ZERO_UPSTREAM_DB = "postgresql://postgres:pass@db.internal:5432/zero"
  ZERO_CVR_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_CHANGE_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_ADMIN_PASSWORD = "pickanewpassword"
  ZERO_REPLICA_FILE = "/data/replica.db"
  ZERO_NUM_SYNC_WORKERS = "0"
  ZERO_LITESTREAM_BACKUP_URL = "s3://acme-zero-backups/v1"

# view-syncer/fly.toml
app = "zero-view-syncer"
primary_region = "iad"
kill_timeout = 300

[build]
  image = "rocicorp/zero:1.8.0"

# If you run more than one view-syncer on Fly, add sticky routing
# (for example Fly Replay / replay_cache) so clients stay on one machine.

[http_service]
  internal_port = 4848
  force_https = true
  auto_stop_machines = "off"
  min_machines_running = 1

# View-syncers are public, so their health checks attach to [http_service].
[[http_service.checks]]
  protocol = "https"
  path = "/"
  interval = "5s"
  timeout = "5s"
  grace_period = "10m"

[mounts]
  source = "view_syncer_data"
  destination = "/data"

[env]
  ZERO_UPSTREAM_DB = "postgresql://postgres:pass@db.internal:5432/zero"
  ZERO_CVR_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_CHANGE_DB = "postgresql://postgres:pass@pgbouncer.internal:5432/zero"
  ZERO_ADMIN_PASSWORD = "pickanewpassword"
  ZERO_QUERY_URL = "https://api.example.com/api/zero/query"
  ZERO_MUTATE_URL = "https://api.example.com/api/zero/mutate"
  ZERO_ENABLE_CRUD_MUTATIONS = "false"
  ZERO_REPLICA_FILE = "/data/replica.db"
  ZERO_CHANGE_STREAMER_URI = "ws://zero-replication-manager.internal:4849/"
```

**SST**

```ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: 'zero',
      home: 'aws',
      removal:
        input?.stage === 'production' ? 'retain' : 'remove'
    }
  },
  async run() {
    const backups = new sst.aws.Bucket('ZeroBackups')
    const vpc = new sst.aws.Vpc('ZeroVpc')
    const cluster = new sst.aws.Cluster('ZeroCluster', {
      vpc
    })

    const commonEnv = {
      ZERO_UPSTREAM_DB:
        'postgresql://postgres:pass@postgres:5432/zero',
      ZERO_CVR_DB:
        'postgresql://postgres:pass@pgbouncer:5432/zero',
      ZERO_CHANGE_DB:
        'postgresql://postgres:pass@pgbouncer:5432/zero',
      ZERO_ADMIN_PASSWORD: 'pickanewpassword',
      ZERO_REPLICA_FILE: 'replica.db'
    }

    const replicationManager = new sst.aws.Service(
      'ReplicationManager',
      {
        cluster,
        image: 'rocicorp/zero:1.8.0',
        cpu: '1 vCPU',
        memory: '2 GB',
        environment: {
          ...commonEnv,
          ZERO_NUM_SYNC_WORKERS: '0',
          ZERO_LITESTREAM_BACKUP_URL: `s3://${backups.name}/v1`
        },
        health: {
          command: [
            'CMD-SHELL',
            'curl -f http://localhost:4849/keepalive || exit 1'
          ],
          startPeriod: '300 seconds'
        },
        loadBalancer: {
          public: false,
          ports: [{listen: '80/http', forward: '4849/http'}]
        },
        transform: {
          service: {
            healthCheckGracePeriodSeconds: 600
          },
          target: {
            healthCheck: {
              enabled: true,
              path: '/keepalive',
              protocol: 'HTTP',
              interval: 5,
              timeout: 3,
              healthyThreshold: 2
            }
          }
        }
      }
    )

    new sst.aws.Service(
      'ViewSyncer',
      {
        cluster,
        image: 'rocicorp/zero:1.8.0',
        cpu: '2 vCPU',
        memory: '4 GB',
        environment: {
          ...commonEnv,
          ZERO_QUERY_URL:
            'https://api.example.com/api/zero/query',
          ZERO_MUTATE_URL:
            'https://api.example.com/api/zero/mutate',
          ZERO_ENABLE_CRUD_MUTATIONS: 'false',
          ZERO_CHANGE_STREAMER_URI: replicationManager.url
        },
        health: {
          command: [
            'CMD-SHELL',
            'curl -f http://localhost:4848/keepalive || exit 1'
          ],
          startPeriod: '300 seconds'
        },
        loadBalancer: {
          public: true,
          ports: [{listen: '80/http', forward: '4848/http'}]
        },
        transform: {
          service: {
            healthCheckGracePeriodSeconds: 600
          },
          target: {
            healthCheck: {
              enabled: true,
              path: '/keepalive',
              protocol: 'HTTP',
              interval: 5,
              timeout: 3,
              healthyThreshold: 2
            },
            stickiness: {
              enabled: true,
              type: 'lb_cookie',
              cookieDuration: 120
            }
          }
        }
      },
      {dependsOn: [replicationManager]}
    )
  }
})
```

**Kubernetes**

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: replication-manager
spec:
  replicas: 1
  selector:
    matchLabels:
      app: replication-manager
  template:
    metadata:
      labels:
        app: replication-manager
    spec:
      terminationGracePeriodSeconds: 600
      containers:
        - name: replication-manager
          image: rocicorp/zero:1.8.0
          ports:
            - name: http
              containerPort: 4849
          env:
            - name: ZERO_UPSTREAM_DB
              value: postgresql://postgres:pass@postgres:5432/zero
            - name: ZERO_CVR_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_CHANGE_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_ADMIN_PASSWORD
              value: pickanewpassword
            - name: ZERO_REPLICA_FILE
              value: /data/replica.db
            - name: ZERO_NUM_SYNC_WORKERS
              value: '0'
            - name: ZERO_LITESTREAM_BACKUP_URL
              value: s3://acme-zero-backups/v1
          volumeMounts:
            - name: data
              mountPath: /data
          startupProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
            failureThreshold: 120
          readinessProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /keepalive
              port: http
            periodSeconds: 10
      volumes:
        - name: data
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: replication-manager-service
spec:
  type: ClusterIP
  selector:
    app: replication-manager
  ports:
    - name: http
      port: 4849
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: view-syncer
spec:
  replicas: 2
  selector:
    matchLabels:
      app: view-syncer
  template:
    metadata:
      labels:
        app: view-syncer
    spec:
      terminationGracePeriodSeconds: 600
      containers:
        - name: view-syncer
          image: rocicorp/zero:1.8.0
          ports:
            - name: http
              containerPort: 4848
          env:
            - name: ZERO_UPSTREAM_DB
              value: postgresql://postgres:pass@postgres:5432/zero
            - name: ZERO_CVR_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_CHANGE_DB
              value: postgresql://postgres:pass@pgbouncer:5432/zero
            - name: ZERO_ADMIN_PASSWORD
              value: pickanewpassword
            - name: ZERO_QUERY_URL
              value: https://api.example.com/api/zero/query
            - name: ZERO_MUTATE_URL
              value: https://api.example.com/api/zero/mutate
            - name: ZERO_ENABLE_CRUD_MUTATIONS
              value: 'false'
            - name: ZERO_REPLICA_FILE
              value: /data/replica.db
            - name: ZERO_CHANGE_STREAMER_URI
              value: ws://replication-manager-service:4849/
          lifecycle:
            preStop:
              exec:
                command: ['sh', '-c', 'sleep 10']
          volumeMounts:
            - name: data
              mountPath: /data
          startupProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
            failureThreshold: 120
          readinessProbe:
            httpGet:
              path: /
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /keepalive
              port: http
            periodSeconds: 10
      volumes:
        - name: data
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: view-syncer-service
spec:
  type: LoadBalancer
  selector:
    app: view-syncer
  sessionAffinity: ClientIP
  ports:
    - name: http
      port: 4848
      targetPort: http
```

In multi-node deployments, keep `ZERO_LITESTREAM_BACKUP_URL` on the `replication-manager` only and point it at an AWS S3 bucket.

The view-syncers in the multi-node topology can be horizontally scaled as needed.

If restores or initial syncs take a while, configure your orchestrator to allow a startup grace period before treating startup checks as a failure. Ten minutes is a good default for most apps. For example, Docker Compose uses `healthcheck.start_period`, Fly.io uses `grace_period`, and ECS services can use `healthCheckGracePeriodSeconds`. Increase it if replica restore or initial sync routinely takes longer.

Likewise, during deploys, give `zero-cache`, `replication-manager`, and `view-syncer` a generous shutdown grace period so they can finish cleanup and drain websocket connections.

## Replica Lifecycle

Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start.

## Performance

You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is "hydrate once, then incrementally push diffs" against the ZQL pipeline, so performance is mostly about:

1. How fast the server can materialize a subscription the first time (hydration).
2. How fast it can keep it up to date (IVM advancement).

Different bottlenecks dominate each phase.

### Hydration

* **SQLite read cost**: hydration is essentially "run the query against the replica and stream all matching rows into the pipeline", so it's bounded by [SQLite scan/index performance](https://zero.rocicorp.dev/docs/debug/inspector#analyzing-queries) + result size.
* **Churn / TTL eviction**: if queries get [evicted](https://zero.rocicorp.dev/docs/queries#query-caching) (inactive long enough) and then get re-requested, you pay hydration again.
* **Custom query transform latency**: the HTTP request from zero-cache to your API at [`ZERO_QUERY_URL`](https://zero.rocicorp.dev/docs/zero-cache-config#query-url) does transform/authorization for queries, adding network + CPU before hydration starts.

### IVM advancement

* **Replication throughput**: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances.
* **Change volume per transaction**: advancement cost scales with number of changed *rows*, not number of queries.
* **Circuit breaker behavior**: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades "slow incremental" for "rehydrate").

### System-level

* **Number of client groups per sync worker**: each client group has its own pipelines; CPU and memory per group limits how many can be "fast" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. [`ZERO_YIELD_THRESHOLD_MS`](https://zero.rocicorp.dev/docs/zero-cache-config#yield-threshold-ms).
* **SQLite concurrency limits**: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO.
* **Network to clients**: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing.
* **Network between services**: for a single-region deployment, all services should be colocated.

## Networking

View syncers must be publicly reachable by clients on port 4848. The replication-manager must only be reachable by view-syncers over your private network on port 4849.

> **Do not expose replication-manager to the internet**: The replication-manager serves Zero's internal replication protocol. Keep it behind private networking such as a private service address, internal load balancer, or Kubernetes `ClusterIP` service.

The external load balancer for view-syncers must support websockets, and can use the health check at `/keepalive` to verify view-syncers are healthy. The replication-manager should also have a `/keepalive` health check, but that check should run through private infrastructure rather than a public load balancer.

### Sticky Sessions

View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state.

If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same `clientGroupID`, and the "loser" will eventually force clients to reconnect.

## Rolling Updates

Zero supports zero-downtime updates by rolling out changes in the following order:

1. Upgrade replication-manager and wait for it to start up.
2. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated).
3. Update the API servers (your mutate and query endpoints).
4. Update client(s).
5. After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables.

> 💡 **Separate Zero changes from schema changes**: Rolling out Zero version changes and [schema changes](https://zero.rocicorp.dev/docs/schema#schema-changes) together is complicated because both require specific ordering, and the ordering depends on the type of schema change.
>
> For this reason, we recommend separating the two types of changes into different PRs and deployments.

### Client/Server Version Compatibility

Servers are compatible with any client of same major version, and with clients one major version back.

For example, server `2.2.0` is compatible with:

* Client `2.3.0` (same major version)
* Client `2.1.0` (same major version)
* Client `1.0.0` (previous major version)

But server `2.2.0` is **not** compatible with:

* Client `3.0.0` (next major version)
* Client `0.1.0` (two major versions back)

To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend.

### Configuration

The zero-cache image is configured via environment variables. See [zero-cache Config](https://zero.rocicorp.dev/docs/zero-cache-config) for available options.

**For AI agents**: to view all the available documentation, visit https://zero.rocicorp.dev/llms.txt