Replicate Elasticsearch Indices to BigQuery

What you’ll build

A live Elasticsearch → BigQuery pipeline that backfills existing documents using sliced Point-in-Time (PIT) snapshots, then continuously ingests new and updated documents by polling a monotonic watermark field. New writes in Elasticsearch appear in BigQuery within cdc_poll_interval + cdc_safety_lag (≈ 30 s by default).

Prerequisites

Before starting, complete the setup steps in Elasticsearch source prerequisites and BigQuery IAM setup.

You’ll also need:


Step 1 — Set up BigQuery

# Enable the API
gcloud services enable bigquery.googleapis.com --project=my-project

# Create the destination dataset
bq mk --dataset --location=US my-project:replication

# Create a service account and grant access
gcloud iam service-accounts create nanosync --project=my-project

gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:nanosync@my-project.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataEditor"

gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:nanosync@my-project.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"

# Download the key
gcloud iam service-accounts keys create nanosync-bq-key.json \
  --iam-account=nanosync@my-project.iam.gserviceaccount.com

On GKE, skip the key — Workload Identity works automatically. Omit credentials_file from the config below.


Step 2 — Create the Elasticsearch API key

POST /_security/api_key
{
  "name": "nanosync",
  "role_descriptors": {
    "nanosync_reader": {
      "cluster": ["monitor"],
      "indices": [
        {
          "names": ["orders-*"],
          "privileges": ["read", "view_index_metadata"]
        }
      ]
    }
  }
}

Copy the encoded field from the response — that’s the value you’ll pass as api_key.

Verify the configured indices are readable and the watermark field exists:

curl -H "Authorization: ApiKey $ES_API_KEY" \
  "https://es-1.prod:9200/orders-*/_mapping?filter_path=*.mappings.properties.@timestamp"

Step 3 — Create the config files

Nanosync uses two separate YAML files: one for the server (database, notifications) and one for pipeline definitions (applied separately).

Create server.yaml:

# Server config — passed to nanosync start
# No pipeline definitions here.

Create pipelines.yaml:

connections:
  - name: my-elasticsearch
    type: elasticsearch
    dsn: "https://es-1.prod:9200"
    properties:
      api_key: "${env:ES_API_KEY}"
      ca_cert: "/etc/ssl/es-ca.pem"

  - name: my-bigquery
    type: bigquery
    properties:
      project_id:       my-project
      dataset_id:       replication
      credentials_file: /path/to/nanosync-bq-key.json

pipelines:
  - name: orders-to-bigquery
    replication_type: cdc_backfill
    source:
      connection: my-elasticsearch
      properties:
        index_pattern:       "orders-*"
        exclude_indices:     "orders-archive-*"
        snapshot_slices:     "0"           # 0 = auto (primary shard count)
        page_size:           "1000"
        cdc_watermark_field: "@timestamp"
        cdc_poll_interval:   "2s"
        cdc_safety_lag:      "30s"
    sink:
      connection: my-bigquery
      properties:
        table_id: orders

Secrets never go in the config file. Use ${env:VAR_NAME} — values are injected from environment variables at startup.

cdc_safety_lag must be larger than your Elasticsearch index refresh_interval (default 1s). On busy clusters with variable indexing lag, bump it to 60s or more — see the Elasticsearch source reference.


Step 4 — Test your connections

export ES_API_KEY=...

nanosync test connection my-elasticsearch
# ✓  my-elasticsearch connected in 41 ms

nanosync test connection my-bigquery
# ✓  my-bigquery connected in 34 ms

If either fails, the error tells you exactly what’s wrong:

✗  my-elasticsearch: preflight: index "orders-2026.06" has _source disabled

Fix the issue before continuing.


Step 5 — Start the server and apply pipelines

# Start the server (handles state store, notifications, etc.)
nanosync start dev --config server.yaml
nanosync serving — Ctrl-C to stop
  REST API: http://localhost:7600/v1
  Web UI:   http://localhost:7600/app

Then apply your pipeline definitions in a separate terminal:

export ES_API_KEY=...

nanosync apply --file pipelines.yaml

The pipeline starts immediately. Nanosync opens a PIT per primary shard and streams the snapshot in parallel, then switches to watermark polling once every partition reports complete.


Step 6 — Watch it work

nanosync monitor
NAME                 SOURCE              TARGET     STATUS          LAG    EV/S
orders-to-bigquery   my-elasticsearch    bigquery   ● snapshotting  —      —
                                                    [████████░░░░]  67%  1,800,000 docs

orders-to-bigquery   my-elasticsearch    bigquery   ● live CDC      28s    1,250

The CDC LAG in steady state hovers around your configured cdc_safety_lag — that is the trailing-edge offset of the polling window, not a sign of trouble.


Step 7 — Kill it and restart

Press Ctrl-C to stop the server, then restart it:

nanosync start dev --config server.yaml
INF pipeline resuming  name=orders-to-bigquery  watermark=2026-06-06T18:14:22.117Z
INF cdc polling        index=orders-2026.06 last_seen=2026-06-06T18:14:22.117Z

No re-snapshot. PIT IDs are not persisted (they expire server-side), so nanosync opens a fresh PIT and resumes polling from the last watermark saved to the checkpoint store. Snapshot partitions that completed before the restart are skipped; only partitions that were running re-run.


Production considerations

Watermark lag monitoring

Watch nanosync_source_elasticsearch_watermark_lag_seconds{index} — in steady state it should hover around cdc_safety_lag. If it grows without bound, either:

Schema drift

If you add a field to source documents that isn’t in the cached Elasticsearch mapping, nanosync emits a SchemaDelta event and adds the column to BigQuery on the next batch. Type changes pause the pipeline at pending_schema_approval — manual resolution is required. See Configuration reference for schema_drift_mode.

Deletes

Because polling can’t observe document removals, deletes in Elasticsearch leave dangling rows in BigQuery. Options:

_source discipline

The connector cannot reconstruct documents from doc-values alone. Any index added to the pipeline must have _source enabled in its mapping. The preflight check catches this on nanosync apply — fix it at the index template level so future indices inherit it.

How to know it’s working

# Check lag is in the right ballpark (≈ cdc_safety_lag, not growing)
nanosync monitor --pipeline orders-to-bigquery

# Verify rows in BigQuery
bq query --use_legacy_sql=false \
  'SELECT COUNT(*) FROM `my-project.replication.orders`'

# Compare with source
curl -H "Authorization: ApiKey $ES_API_KEY" \
  "https://es-1.prod:9200/orders-*/_count"

Source and destination counts should match modulo the cdc_safety_lag window and any deletes that occurred since the snapshot.


What’s next