Skip to content

Brownfield & greenfield resource adoption

How stacks adopt existing Cloudflare resources into Tamer state without putting account IDs in config.

North star

PrincipleMeaning
No CF IDs in configUUIDs and bucket names live in state only, filled by sync / apply
Name-based matchingTamer derives expected names from config; sync lists the account API and adopts matches
Brownfield = namingLegacy resources keep their Cloudflare names; reproduce those patterns in naming (functions, not IDs)
tamer import --cf-idEscape hatch for one-off gaps — not the primary migration path

Some resources cannot be renamed on Cloudflare (D1, R2, workflows). Adoption must align logical names + naming hooks with what already exists.

Greenfield vs brownfield

GreenfieldBrownfield
CF namesTamer defaults (db_{logical}_{env}, r2-{logical}-{env}, …)Legacy names via naming block
Configstack, workers, resources onlySame + naming: { d1Single, d1Shard, r2Bucket, workerName, workflow, … }
First-time flowbootstrapsyncplanapply → …Same — naming must match CF before sync adopts
IDs in configNeverNever

Standard command sequence

From the stack repo root (after CLOUDFLARE_* creds and tamer/project.config.ts):

bash
tamer doctor --env dev
tamer bootstrap          # once per account
tamer sync --env dev               # discover existing resources into state
tamer plan --env dev               # preview creates/updates vs config
tamer apply --env dev
tamer migrate --env dev            # if D1 declared
tamer deploy --env dev

Use tamer drift --env dev when state and Cloudflare disagree. Re-run sync after out-of-band changes.

Worker secrets: Tamer cannot import existing CF secret values — seed the vault from .dev.vars / 1Password and push; see secrets.md § Adoption paths.

Multi-stack: bootstrap once per account; apply producers before consumers so ${tamer:import:…} resolves (Quickstart § Cross-stack).

naming block (supported today)

Declare on the root defineConfig({ … }) document. API details (defaults, date parameter, sync matching): JSDoc on NamingConventions in @dragonmastery/tamer (hover in your IDE; source in src/types.ts in this repo).

HookUsed forDefault when omitted
d1Singled1 resources with type: "single"db_{logical}_{env}
d1Shardd1 resources with type: "sharded"db_{logical}_{YYYYMMDD}_{env}
r2BucketR2 bucketsr2-{logical}-{env} (date arg is YYYYMMDD at apply time; ignore if unused)
workerNameDeployed Worker script names{stack}-{workerKey}-{env} (or scriptName override)
workflowWorkflow registration nameswf-{logical}-{env} (lowercase)

Each hook receives the stack identity (the stack string) as a parameter so custom formulas can embed it.

Not configurable via naming today: KV, queues, hyperdrive, vectorize, pipelines, AI gateway, secrets store, dispatch namespaces, DNS. Those use built-in derived names.

Reference fixture: fixtures/platform/tamer/project.config.ts (d1Single + r2Bucket for legacy-style names).

Config vs state

  • Config: logical names, resource shapes, and naming functions (how to compute CF names).
  • State (tamer-state): cfId / UUIDs, route ids, workflow ids, etc., after sync or apply.
  • Wrangler output: bindings use state; generated config must not embed raw Cloudflare IDs.

Minimal brownfield example

Existing account: D1 single acme-app-dev, sharded history DBs acme-history-20250115-dev, R2 acme-assets-dev, worker script acme-api-dev, workflow acme-ingest-dev.

ts
import { defineConfig } from "@dragonmastery/tamer";

export default defineConfig({
  stack: "acme",
  compatibility_date: "2025-12-01",
  naming: {
    d1Single: (logical, _stack, env) => `acme-${logical}-${env}`,
    d1Shard: (logical, date, _stack, env) => {
      const d = date.replace(/-/g, "");
      return `acme-${logical}-${d}-${env}`;
    },
    r2Bucket: (logical, _date, _stack, env) => `acme-${logical}-${env}`,
    workerName: (stack, workerKey, env) => `${stack}-${workerKey}-${env}`,
    workflow: (logical, _stack, env) => `acme-${logical}-${env}`,
  },
  workers: {
    api: {
      main: "workers/api/src/index.ts",
      resources: {
        d1: [
          { logicalName: "app", type: "single" },
          { logicalName: "history", type: "sharded" },
        ],
        r2: [{ logicalName: "assets" }],
        workflows: [{ logicalName: "ingest", className: "IngestWorkflow" }],
      },
    },
  },
});

Then: bootstrapsyncplanapplydeploy.

How sync matches resources

ResourceMatch strategy
D1 singleExact derived name from d1Single
D1 shardedDefault: prefix/suffix regex on db_{logical}_…_{env}. Custom d1Shard: derive-and-match — extract shard date from the CF name (YYYYMMDD or YYYY_MM_DD), re-derive via your hook, adopt on equality
R2Exact derived name (custom r2Bucket uses today's date, same as apply)
WorkflowsExact derived name from workflow hook (or default wf-…)

sync, drift, plan, apply, and import all use the same NamingEngine derivation — configure hooks once.

Workflows

Workflow names are immutable on Cloudflare. For brownfield adoption, add a naming.workflow function that returns the live registration name:

ts
naming: {
  workflow: (logical, stack, env) => {
    if (env === "dev") return `${logical}-workflow-${env}`;
    return `${stack}-app-saai-${logical}-workflow-${env}`;
  },
},

Declare workflows under resources.workflows with matching logical names. tamer sync --env dev adopts workflows whose CF name equals the derived name.

If a hook is omitted, the default is wf-{logical}-{env} (lowercase). Use tamer import --target workflow:<name> --cf-id <id> only when sync cannot match (e.g. one-off typo in a legacy name you cannot encode in a function).

Escape hatch: tamer import

bash
tamer import --env dev --target d1:app --cf-id <d1-uuid>
tamer import --env dev --target d1:history --shard-date 2025-01-15 --cf-id <d1-uuid>
tamer import --env dev --target r2:assets --cf-id <bucket-name>
tamer import --env dev --target workflow:ingest --cf-id <workflow-id>

Verifies the Cloudflare object exists and that its name matches the name your naming hooks (or defaults) produce. Does not rename resources. Prefer sync for bulk adoption when names align.

Gaps & limitations (honest)

AreaToday
KV / queue / hyperdrive / …Per-resource cloudflareName overrides (stack naming.* hooks optional future)
R2 sync with custom r2BucketExact name match only (today's date used when calling the hook, same as apply)
Tenant runtime D1 (wfp tenant provision)Separate naming (db_{role}_{product}_{workspace}_{env}) — not covered by stack naming hooks
Shard dates in exotic formatsextractD1ShardDate recognizes _{YYYYMMDD}_ and _{YYYY_MM_DD}_; other date encodings need import per shard

Per-resource cloudflareName

When legacy Cloudflare names differ per logical resource (not one formula for the whole stack), set optional cloudflareName on each managed resource config:

ts
workflows: [
  {
    logicalName: "referral-event",
    className: "ReferralEventWorkflow",
    cloudflareName: (stack, env) =>
      env === "dev"
        ? "referral-event-workflow-dev"
        : `${stack}-app-saai-referral-event-workflow-${env}`,
  },
],

Resolution order: resource.cloudflareName → stack naming.{kindHook} → {@link NamingEngine} default. logicalName stays stable for state keys and ${tamer:…} refs; IDs remain in state after sync / apply.

Use stack-level naming.* for shared formulas (e.g. all D1 shards); use cloudflareName when dev/prod names differ per workflow or shard prefix (db_ vs dbh_). External D1 keeps databaseName; workers use scriptName.

Released under the Tamer Evaluation License.