---
title: Quickstart
description: Deploy the Ads Sync Reference Deployment to your own Cloudflare account and Postgres, and complete one scheduled Qualified Run in under an hour.
---

This quickstart takes you from a fresh Cloudflare account and a fresh Postgres database to one completed, scheduled Qualified Run. You deploy the Ads Sync Reference Deployment, one Sync Connection, one Supported Provider (Google Search Console), and one Cron trigger.

Everything here is single-tenant custody (ADR 0047). Your credentials live in your own Cloudflare Worker secrets, or in the local Wrangler dev vars file when you run locally. There is no broker, no registry, and no token exchange. The Reference Deployment never writes secret material to Postgres, R2, logs, or artifacts.

Read [Google Search Console caveats](/explanation/google-search-console-caveats) before you create the OAuth client. The Testing-mode refresh token expires after seven days.

## before you start

| you need | why |
| --- | --- |
| A Cloudflare account on Workers Paid | Cloudflare Containers require Workers Paid. |
| Node.js 24, pnpm 10 or newer, Git | The repository toolchain. |
| Docker running locally | Wrangler builds the connector images from the shipped Dockerfiles at deploy time. |
| `psql` | Applies the schema. Any Postgres client that reads SQL from stdin also works. |
| A reachable Postgres database and one role | The role needs database-level `CREATE`. Airbyte owns the DDL and creates its own schemas. Do not pre-create schemas or use a schema-scoped writer. |
| A Google Search Console property you can read | The provider data source. |
| A Google Cloud project | Hosts the OAuth client for the Search Console API. |

Set one variable for the rest of the tutorial:

```bash
export DATABASE_URL="postgres://<role>:<password>@<host>:<port>/<database>?sslmode=require"
```

The Reference Deployment reaches Postgres two ways: the Worker reads through Hyperdrive, and the destination Container writes to Postgres directly. Both need the same database.

## 1. get the code

```bash
git clone https://github.com/patronage/agentic-marketing-connectors.git
cd agentic-marketing-connectors
pnpm install
pnpm --filter @patronage/ads-sync build
pnpm --filter @patronage/ads-sync-deploy check
```

`check` runs type checking, the deployment tests, and the public-import boundary guard. The rest of the tutorial runs from the Reference Deployment directory:

```bash
cd packages/ads-sync/deploy
```

## 2. create the Cloudflare resources

```bash
pnpm exec wrangler login
pnpm exec wrangler r2 bucket create ads-sync-raw
pnpm exec wrangler hyperdrive create ads-sync-quickstart --connection-string="$DATABASE_URL" --caching-disabled
```

`--caching-disabled` matters. Hyperdrive caches query results for 60 seconds by default, and the Worker reads the committed watermark through Hyperdrive. With caching on, `GET /runs` reports `watermark: null` for up to a minute after a run succeeds, and a tick in that minute queues the same window again instead of answering `no_new_final_data`.

`hyperdrive create` prints an `id`. Paste it into `wrangler.jsonc` in place of the placeholder:

```jsonc
"hyperdrive": [
  {
    "binding": "HYPERDRIVE",
    "id": "<the id hyperdrive create printed>",
  },
],
```

Keep the bucket name `ads-sync-raw`, or change both the bucket and `r2_buckets[0].bucket_name` together. If your account already has a Worker named `patronage-ads-sync-reference-deploy`, change `name` in `wrangler.jsonc` too; the Container application names derive from it. Do not put secrets in `wrangler.jsonc`.

## 3. apply the schema

The package ships the control schema and the reporting views as SQL text. The Reference Deployment ships the schedule tables. Apply all three with `psql`:

```bash
node -e "import('@patronage/ads-sync').then(({ controlSchemaSql, reportingViewSql }) => console.log(\`\${controlSchemaSql}\n\${reportingViewSql}\`))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
node --experimental-strip-types -e "import('./src/schedule.ts').then(({ scheduleSchemaSql }) => console.log(scheduleSchemaSql))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
```

`NOTICE` lines about columns and views that already exist are normal. The statements are idempotent. Confirm the tables:

```bash
psql "$DATABASE_URL" -c "\dt ads_sync.*"
```

You get 16 tables, including `sync_runs`, `sync_watermarks`, and `sync_schedule_ticks`. The Airbyte schemas `airbyte_google_search_console` and `airbyte_internal` do not exist yet. The destination Container creates them on the first run. That is why the role needs database-level `CREATE`.

## 4. create the Google Search Console credential

In the Google Cloud console:

1. Enable the **Google Search Console API** for your project.
2. Configure the OAuth consent screen. Add the Google account that owns the Search Console property as a test user.
3. Create an OAuth client of type **Desktop app**. Note the client id and client secret.

Get a refresh token. Open this URL in a browser, with your client id in place of the placeholder, and approve the `webmasters.readonly` scope:

```text
https://accounts.google.com/o/oauth2/v2/auth?client_id=<client-id>&redirect_uri=http://localhost:8080&response_type=code&scope=https://www.googleapis.com/auth/webmasters.readonly&access_type=offline&prompt=consent
```

The browser redirects to `http://localhost:8080/?code=<code>&scope=...`. The page does not load because nothing listens there. Copy the `code` value from the address bar and exchange it:

```bash
curl -s https://oauth2.googleapis.com/token \
  -d client_id="<client-id>" \
  -d client_secret="<client-secret>" \
  -d code="<code>" \
  -d grant_type=authorization_code \
  -d redirect_uri=http://localhost:8080
```

The response contains `refresh_token`. Write the source config to a file outside the repository. Do not commit it:

```bash
mkdir -p ~/.config/ads-sync
cat > ~/.config/ads-sync/gsc-source-config.json <<'EOF'
{
  "authorization": {
    "auth_type": "Client",
    "client_id": "<client-id>",
    "client_secret": "<client-secret>",
    "refresh_token": "<refresh-token>"
  },
  "custom_reports_array": [
    { "dimensions": ["query", "page"], "name": "search_analytics_query_page" }
  ],
  "data_state": "final",
  "site_urls": ["https://example.org/"],
  "start_date": "2026-01-01"
}
EOF
```

`site_urls` is the exact property URL as Search Console shows it (`https://example.org/` for a URL-prefix property, `sc-domain:example.org` for a domain property). `start_date` bounds the connector; the schedule narrows every run to one window from the committed watermark to the Final-Data Horizon.

## 5. deploy

Dry-run first, then deploy:

```bash
pnpm --filter @patronage/ads-sync-deploy build
pnpm --filter @patronage/ads-sync-deploy run deploy
```

The word `run` is required for `deploy`. Without it pnpm executes its own built-in `pnpm deploy` command and stops with `ERR_PNPM_INVALID_DEPLOY_TARGET`.

`build` is a Wrangler dry run. It lists the bindings and the four Container classes and exits. `run deploy` builds the four connector images from `images/` with Docker, pushes them to your Cloudflare account's container registry, and publishes the Worker with its Cron trigger (`0 6 * * *`, UTC). The first deploy takes several minutes because of the image builds; with warm Docker layers it takes about one minute. Wrangler prints the Worker URL at the end:

```bash
export WORKER_URL="https://patronage-ads-sync-reference-deploy.<your-subdomain>.workers.dev"
```

## 6. set the Worker secrets

The Reference Deployment reads exactly three secrets for this connection. Every name is in the managed set the custody adapter enforces; any other name fails closed.

Write the destination config to a second file outside the repository:

```bash
cat > ~/.config/ads-sync/postgres-destination-config.json <<'EOF'
{
  "host": "<postgres-host>",
  "port": 5432,
  "database": "<database>",
  "username": "<role>",
  "password": "<password>",
  "schema": "airbyte_google_search_console",
  "raw_data_schema": "airbyte_internal",
  "ssl": true,
  "ssl_mode": { "mode": "require" },
  "jdbc_url_params": "sslmode=verify-full",
  "tunnel_method": { "tunnel_method": "NO_TUNNEL" },
  "unconstrained_number": true,
  "drop_cascade": false
}
EOF
```

`ssl_mode: require` is the Airbyte server-auth TLS mode. `jdbc_url_params: sslmode=verify-full` makes the JDBC driver verify the server certificate against the CA bundle the shipped destination image pins. Keep both.

Now set the secrets:

```bash
export ADS_SYNC_RUNNER_TOKEN="$(openssl rand -hex 32)"
printf '%s' "$ADS_SYNC_RUNNER_TOKEN" | pnpm exec wrangler secret put ADS_SYNC_RUNNER_TOKEN
pnpm exec wrangler secret put POSTGRES_DESTINATION_CONFIG_JSON < ~/.config/ads-sync/postgres-destination-config.json
pnpm exec wrangler secret put GOOGLE_SEARCH_CONSOLE_SOURCE_CONFIG_JSON < ~/.config/ads-sync/gsc-source-config.json
```

Keep `ADS_SYNC_RUNNER_TOKEN` in your shell; `/runs` requires it as a bearer token, and Worker secrets cannot be read back. Each `secret put` publishes a new Worker version with the secret attached. Until the runner token is set, `POST /runs` answers `503`; until the other two are set, the tick fails when the seam reads them.

`ads-sync.config.ts` already declares this Sync Connection: `google_search_console_default`, destination schema `airbyte_google_search_console`, stream `search_analytics_query_page`, secret name `GOOGLE_SEARCH_CONSOLE_SOURCE_CONFIG_JSON`. It stores secret names only. You do not need to edit it for this tutorial.

## 7. run the first Qualified Run

```bash
curl -s "$WORKER_URL/health"
curl -s "$WORKER_URL/"
```

`/health` answers `{"ok":true,"service":"ads-sync-reference-deployment"}`. `/` lists the configured connection, the Supported Providers, and the routes `GET /`, `GET /health`, `GET /runs`, `GET /runs/:runId`, and `POST /runs`. To follow a run live, keep `pnpm exec wrangler tail --format json` open in a second terminal.

Trigger one tick now instead of waiting for 06:00 UTC:

```bash
curl -s -X POST -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"
```

The answer is `202` with `result: "queued"`, a `runId`, the read mode `final`, the Final-Data Horizon, and the window. The Worker hands the run to the `RunDispatcher` Durable Object (`execution: "durable_object_alarm"`), whose alarm executes it, so a first run with two cold Containers completes without the request staying open. `orphanedRunIds` lists runs the tick failed closed because they lost their executor; on a fresh deployment it is empty:

```json
{
  "execution": "durable_object_alarm",
  "horizon": "2026-08-15T00:00:00.000Z",
  "orphanedRunIds": [],
  "readMode": "final",
  "result": "queued",
  "runId": "<uuid>",
  "window": {
    "id": "<uuid>",
    "windowEnd": "2026-08-15T00:00:00.000Z",
    "windowStart": "2026-07-16T00:00:00.000Z"
  }
}
```

The horizon is UTC midnight three days before today for Google Search Console. Before the first commit the window starts one provider step (30 days) before the horizon. Poll until the run is `succeeded`:

```bash
curl -s -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"
```

A second `POST /runs` while this run is `queued` or `running` answers `200` with `result: "already_active"` and the same `runId`; it never queues a duplicate. The first run starts two Containers cold. The seam retries an unavailable Container up to five times, two seconds apart; that retry is seam behavior, not something you configure. A run that fails with `container did not respond to /metadata after 5 attempt(s)` names a deployment problem, not a credential problem. Expect a few minutes for the first run; the walkthrough run took 37 seconds from tick to `succeeded` for a 30-day window.

When the run is `succeeded`, `watermark` equals `windowEnd`, and the rows are in Postgres:

```bash
psql "$DATABASE_URL" -c "select count(*) from airbyte_google_search_console.search_analytics_query_page"
psql "$DATABASE_URL" -c "select r.status, r.trigger_type, t.read_mode, t.window_start, t.window_end from ads_sync.sync_runs r left join ads_sync.sync_schedule_ticks t on t.run_id = r.id and t.result <> 'already_active' order by r.started_at desc limit 5"
psql "$DATABASE_URL" -c "select connection_id, watermark_end, run_id from ads_sync.sync_watermarks"
```

The Reference Deployment writes the window to `ads_sync.sync_schedule_ticks` (the tick ledger) and the committed watermark to `ads_sync.sync_watermarks`; the first query shows the `succeeded` run with its window, and the second shows `watermark_end` equal to `windowEnd`. The join excludes `already_active` ticks: the second `POST /runs` above wrote one of those with the in-flight `run_id` and no window, and `GET /runs` applies the same predicate, so each run appears once with the window of the tick that planned it.

The reporting view `ads_sync_reporting.gsc_query_page_daily` was created as an empty stub in step 3, because the Airbyte table did not exist yet. Now that it does, apply the reporting views once more; the view then reads the table:

```bash
node -e "import('@patronage/ads-sync').then(({ reportingViewSql }) => console.log(reportingViewSql))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
psql "$DATABASE_URL" -c "select count(*) from ads_sync_reporting.gsc_query_page_daily"
```

Trigger a second tick:

```bash
curl -s -X POST -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"
```

The answer is `200` with `result: "no_new_final_data"`, the `runId` of that no-op run, and the committed `watermark`. The horizon has not moved past the watermark, so no Containers start. (If you created the Hyperdrive config without `--caching-disabled`, wait one minute after the first success before this tick, or it queues the same window again.) That is a first-class run result. The Cron trigger records the same outcome on days when the provider has no new final data. Every tick, with or without a run, is a row in `ads_sync.sync_schedule_ticks` (`result` is `queued`, `no_new_final_data`, or `already_active`).

You now have a scheduled Qualified Run. Each day at 06:00 UTC the Worker reads from the committed watermark to the new Final-Data Horizon and commits the watermark only after the destination write succeeds. To catch up a stale watermark the tick caps each window at 120 days (four windows of 30 days) and continues on the next tick.

## optional: run the HTTP surface locally

You can run the same Worker on your machine. Put the three secret names in the local Wrangler dev vars file beside `wrangler.jsonc` (never commit that file), and point the local Hyperdrive binding at your database:

```bash
WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="$DATABASE_URL" \
  pnpm exec wrangler dev --config wrangler.jsonc --ip 127.0.0.1 --port 8796 --enable-containers=false
```

`--enable-containers=false` skips the local image builds, so `/health`, `/`, and `GET /runs` work and `POST /runs` records a failed run instead of syncing. Local Cron does not fire on its own; `curl "http://127.0.0.1:8796/cdn-cgi/handler/scheduled"` runs one tick.

## cost envelope

The Reference Deployment README carries the [cost envelope](https://github.com/patronage/agentic-marketing-connectors/blob/main/packages/ads-sync/deploy/README.md#cost-envelope) that ADR 0011 requires: the Workers Paid floor, `standard-1` Container active minutes per run, R2 artifact storage and operations, and the Postgres tier. Steady-state daily ticks are cheap; the initial catch-up windows are the peak. Read it before you point the deployment at a large property.

## tear down

```bash
pnpm exec wrangler delete
pnpm exec wrangler containers list
pnpm exec wrangler containers delete <application-id>   # once per Container application of this Worker
pnpm exec wrangler containers images list
pnpm exec wrangler containers images delete <image>:<tag>   # once per image of this Worker
pnpm exec wrangler r2 object delete ads-sync-raw/<key> --remote   # once per artifact object
pnpm exec wrangler r2 bucket delete ads-sync-raw
pnpm exec wrangler hyperdrive delete <hyperdrive-id>
psql "$DATABASE_URL" -c "drop schema if exists airbyte_google_search_console, airbyte_internal, ads_sync_reporting, ads_sync cascade"
```

`wrangler delete` removes the Worker and its Durable Objects but not the four Container applications or the registry images; delete those by id. `r2 bucket delete` refuses a bucket that still holds objects, and `r2 object delete` without `--remote` acts on the local store only. The Cloudflare dashboard can empty the bucket in one action.

Revoke the OAuth grant in your Google account and delete the two config files under `~/.config/ads-sync/`. Delete the local files the quickstart created next to `wrangler.jsonc`: `.dev.vars` holds the client secret, the refresh token, and the database password, and `.wrangler/` holds local state. Both are gitignored, so `wrangler delete` leaves them in place:

```bash
rm -f .dev.vars
rm -rf .wrangler
```

Last verified: 2026-08-18, independent walkthrough, 35 minutes to the first `succeeded` run (with a Cron workaround that #1554 has since removed).

## next

- [Google Search Console caveats](/explanation/google-search-console-caveats): the seven-day Testing-mode refresh token and the cold-start retry.
- [Verify a bounded run](/how-to/verify-a-bounded-run): canary evidence and the Historical Comparison Gate.
- [Ejection Path](/how-to/eject-from-loop): the same deployment as the landing zone for a Loop tenant.
- To sync Google Ads or Meta Ads instead, replace the one connection in `ads-sync.config.ts` with that provider's catalog and secret names, and set that provider's source-config secret. The routes and the schedule are the same.
