Import an existing Ory Network project into Terraform
Onboard a live Ory Network project into Terraform so that terraform plan
reports no changes. Terraform's built-in import blocks and
terraform plan -generate-config-out do the mechanical part; this skill fills
in the Ory-specific knowledge: where each resource is discovered, how each
import ID is built, which attributes to prune from generated config, and which
secrets can never be read back.
Safety first: every step below is read-only against Ory until the final
terraform apply, and that apply only records imports (0 to add, 0 to change, 0 to destroy). Never run terraform destroy in the working directory — the
imported resources are real. To undo a mistaken import, use
terraform state rm <address>; it removes the resource from state without
touching the live project.
Prerequisites
Terraform >= 1.5 (import blocks and -generate-config-out).
curl and jq (used by the inventory script).
A workspace API key (ory_wak_..., create under Workspace Settings > API
keys) and, to cover OAuth2 clients / JWKS / trusted issuers, a project API
key (ory_pat_...).
The project UUID. List a workspace's projects with the workspace API key:
curl -sH "Authorization: Bearer $ORY_WORKSPACE_API_KEY" \
"${ORY_CONSOLE_API_URL:-https://api.console.ory.sh}/workspaces/$ORY_WORKSPACE_ID/projects" \
| jq -r '.projects[] | "\(.id) \(.slug) \(.name)"'
Use this workspace-scoped endpoint, not GET /projects (that path needs a
browser session token and returns 403 for an API key). The ory list projects CLI also works, but only against Ory's production console with
an interactive ory auth session — it ignores ORY_CONSOLE_API_URL, so it
cannot reach other environments, and it rejects --workspace when
ORY_WORKSPACE_API_KEY is set. The curl call above is environment-agnostic.
Export the credentials as environment variables; the provider and the
inventory script both read them:
export ORY_WORKSPACE_API_KEY=ory_wak_...
export ORY_PROJECT_API_KEY=ory_pat_... # optional but recommended
export ORY_PROJECT_ID=<project-uuid>
export ORY_PROJECT_SLUG=<project-slug>
Non-default environments (e.g. staging) also need endpoint overrides, both for
the script and the provider block: ORY_CONSOLE_API_URL (default
https://api.console.ory.sh) and ORY_PROJECT_API_URL (a printf template,
default https://%s.projects.oryapis.com).
Workflow
1. Inventory the project and build import blocks
Run the bundled script from an empty working directory:
<path-to-skill>/scripts/generate-imports.sh > imports.tf
It dumps the project revision (GET /projects/{id}), walks every list
endpoint, and emits one import block per importable resource with the
correct ID format (see the reference table below). Data-plane objects
(identities, relationship tuples) and unrecoverable ones (project API key
values) come out as explanatory comments instead of import blocks.
Review imports.tf and delete blocks for resources you do not want Terraform
to manage. Everything Terraform imports it will also plan to change or destroy
later, so import deliberately.
2. Scaffold the working directory
# versions.tf
terraform {
required_providers {
ory = { source = "ory/ory" }
}
}
# provider.tf — keys come from the ORY_* environment variables
provider "ory" {
project_id = "<project-uuid>"
project_slug = "<project-slug>"
}
Setting project_id/project_slug on the provider matters: several import IDs
(social/SAML providers, email templates, single-segment forms) resolve the
project from provider configuration.
3. Generate configuration
terraform init
terraform plan -generate-config-out=generated.tf
Expect the "Config generation is experimental" warning; that is fine. If a
resource fails to generate (e.g. conflicting or unknown attributes), remove its
import block, note it, and hand-write that resource later.
4. Refine the generated config
generated.tf is a starting point, not the final code. Apply these rules:
Drop null attributes and empty strings/lists — they are noise and some
(write-only arguments) are invalid to set explicitly.
Drop computed/read-only attributes the API owns (id, timestamps,
revision IDs, state, verification statuses). If Terraform errors with
"Invalid or unknown key" or "value must be configured only by the provider",
delete that attribute. Exception: keep state on ory_scim_client. There
it is a configurable enabled/disabled switch that defaults to
enabled, so dropping it re-enables a disabled client on the first apply.
Re-supply secrets. The API never returns SMTP connection URIs, social
provider client secrets, SCIM client secrets, or OAuth2 client secrets
(values come back masked or absent). Wire them to variable blocks marked sensitive, or use the
write-only variants (client_secret_wo, smtp_connection_uri_wo, ...) with
*_wo_version to keep them out of state entirely.
courier_http_request_config_auth_basic_auth_password,
courier_http_request_config_auth_api_key_value, and
oidc_subject_identifiers_pairwise_salt behave the same way but have
no _wo variant, so they must come from a sensitive variable.
smtp_connection_uri_wo is the only write-only argument on
ory_project_config.
Populate ory_project_config yourself — it generates as an empty shell.
The provider intentionally refreshes only attributes already tracked in
state (so unmanaged settings never drift), and a fresh import tracks
nothing. -generate-config-out therefore emits an all-null block for this
resource. Delete the null lines and add the attributes you want Terraform
to own, copying current values from the revision dump (GET /projects/{id}
under .services.identity.config, .services.oauth2.config,
.services.permission.config, and .services.account_experience.config).
The first plan shows those attributes as additions (+) even when the values
match the server, and the first apply just records them in state because the
PATCH is idempotent. Prefer the spec-derived attribute names over deprecated
aliases (run scripts/migrate-deprecated-attrs.sh from the provider repo if
needed).
Five attributes do not sit where the dump suggests, so copying by name fails:
| Attribute |
Where the dump holds it |
enable_ax_v2 |
.services.account_experience.config.enabled |
disable_account_experience_welcome_screen |
no service config at all; only GET /normalized/projects/{id} |
selfservice_methods_code_config_max_submissions |
reported under ...code.config.max_submissions, written to ...code.max_submissions |
oidc_subject_identifiers_pairwise_salt |
a redacted secret; write-only in the provider, so re-supply it from your secret store |
courier_http_request_config_body |
a https://storage.googleapis.com/.../<sha512>.jsonnet URL, never the payload |
Do not copy the courier_http_request_config_body URL into config. The
API rejects a storage URL it did not mint. Set base64://${base64encode(...)}
with the Jsonnet payload instead; the provider resolves the URL back to it by
comparing the SHA-512 in the URL with the value in state.
Replace literal IDs with references where resources relate, e.g.
project_id = ory_project.main.id instead of the hardcoded UUID.
Split into files (project.tf, oauth2.tf, social.tf, ...) once the
plan converges.
5. Converge
terraform plan
Five ory_project_config attributes never converge if you set them empty,
because the server substitutes its own default or refuses to clear the key. If
the dump shows one of these as empty or absent, omit the attribute rather than
writing an empty value, otherwise this step cannot finish:
account_experience_enabled_locales,
selfservice_methods_passkey_config_rp_origins, webauthn_rp_origins,
selfservice_methods_totp_config_issuer, and
selfservice_methods_captcha_config_allowed_domains.
Iterate on the config until the plan reports only imports:
Plan: N to import, 0 to add, 0 to change, 0 to destroy. Then:
terraform apply # records the imports in state; changes nothing remotely
terraform plan # must now report: No changes.
If the second plan still shows diffs, fix the config (not the project) and
re-plan. Typical causes are listed under Troubleshooting.
6. Aftercare
- Keep the
import blocks in the repo (they are idempotent and document
provenance) or delete them after the apply — either works.
- Add state hygiene: the state now contains project config and possibly
private JWKS keys; store it in an encrypted backend, not in git.
- Commit the refined
.tf files; secrets stay in variables / *_wo arguments.
Resource reference
Console API = https://api.console.ory.sh with the workspace API key.
Project API = https://{slug}.projects.oryapis.com with the project API key.
| Resource |
Discover via |
Import ID |
ory_workspace |
.workspace_id on the project payload (the script emits this one commented out) |
{workspace_id} |
ory_project |
GET /workspaces/{ws}/projects (console) |
{project_id} |
ory_project_config |
GET /projects/{id} (console) |
{project_id} |
ory_custom_domain |
GET /projects/{id}/cname (console) |
{project_id}/{domain_id} |
ory_event_stream |
GET /projects/{id}/eventstreams (console) |
{project_id}/{stream_id} |
ory_organization |
GET /projects/{id}/organizations (console) |
{project_id}/{org_id} |
ory_project_api_key |
GET /projects/{id}/tokens (console) |
{project_id}/{key_id} — value unrecoverable |
ory_social_provider |
revision: .services.identity.config.selfservice.methods.oidc.config.providers[] |
{provider_id} (e.g. google) |
ory_saml_provider |
revision: ...methods.saml.config.providers[] |
{provider_id} |
ory_scim_client |
GET /normalized/projects/{id} (console): .current_revision.scim_clients[] |
{project_id}/{client_id}, secret unrecoverable |
ory_action |
revision: ...selfservice.flows.<flow>.<timing>...hooks[] where hook == "web_hook" |
after: {project_id}:{flow}:after:{auth_method}:{METHOD}:{url}; before: {project_id}:{flow}:before:{METHOD}:{url} |
ory_email_template |
revision: ...courier.templates.<base>.<valid|invalid>.email with non-empty subject or body.html / body.plaintext |
{base}_{valid|invalid} (e.g. recovery_code_valid) |
ory_oauth2_client |
GET /admin/clients (project) |
{client_id} |
ory_oidc_dynamic_client |
same list; GET /admin/clients carries no field that distinguishes a DCR client, so pick them out yourself |
{client_id} (identical to ory_oauth2_client, so switching resource type is a one-word edit) |
ory_json_web_key_set |
GET /admin/keys/{set} (project); no list-all endpoint, set IDs must be known |
{project_id}/{set_id} |
ory_trusted_oauth2_jwt_grant_issuer |
GET /admin/trust/grants/jwt-bearer/issuers (project) |
{grant_id} |
ory_identity |
GET /admin/identities (project) |
{identity_id} |
ory_relationship |
revision: .services.permission.config.namespaces[] + GET /relation-tuples?namespace= (project) |
namespace:object#relation@subject_id, or a subject set namespace:object#relation@subject_ns:subject_obj#subject_rel |
ory_identity_schema |
revision: ...identity.schemas[], plus GET /identity-schemas (console) for workspace-scoped schemas the revision omits |
not importable (immutable by design) |
Single-segment forms ({domain_id}, {org_id}, {key_id}, {stream_id},
{set_id}) also work when the provider block sets project_id; prefer the
explicit {project_id}/... form in generated files.
Caveats
- Identity schemas cannot be imported. They are immutable; leave existing
schemas unmanaged and manage only newly created schemas. Read a single schema
with the
ory_identity_schema data source, or list every schema the project
can see with ory_identity_schemas. The project revision lists only schemas
explicitly added to the project, so the plural data source and the console
GET /identity-schemas endpoint see workspace-scoped schemas the revision
omits.
- Secrets never round-trip. SMTP connection URI, pairwise subject
identifier salt, social/SAML client secrets, SCIM client secrets, OAuth2
client secrets, tokenizer template keys:
re-supply via variables or write-only
*_wo arguments. Until you do, some of
these show a perpetual diff or import as empty.
ory_scim_client imports with an empty secret and a stored mapper URL.
The API redacts authorization_header_secret in every response, and it
rewrites mapper_url into a content-addressed object-storage URL. Set both
in config after the import: the first apply uploads the mapper and rotates
the secret to the configured value, in place. Also import the matching
ory_organization and reference it as
organization_id = ory_organization.<name>.id, because deleting an
organization deletes its SCIM clients server-side.
ory_project_api_key values exist only at creation time. Importing one
yields a resource whose value is null; rotating it through Terraform means
destroy + create (a brand-new key).
ory_trusted_oauth2_jwt_grant_issuer.jwk forces a one-time replace on
import. The public-key jwk is required, forces replacement when changed,
and is never returned by the read endpoint, so import leaves it empty. Supply
the issuer's original public JWK in config; the first apply then destroys and
re-creates the trust once to store it (a functional no-op re-registration —
the issuer/subject/scope are unchanged). Plans after that first apply are
clean. If a transient re-registration is unacceptable, leave the issuer
unmanaged.
- Default
hydra.* JWKS sets (hydra.openid.id-token,
hydra.jwt.access-token) are system-managed — do not import them. Importing
any JWKS puts private key material into state.
- Identities and relationship tuples are data, not configuration. Import
only the handful that are genuinely config-like (service accounts, seed
tuples).
ory_social_provider.auto_link is write-only in the API; an import will
not populate it. Re-add it to config manually where used (enterprise
feature).
- B2B SSO providers come back as
ory_social_provider with an
organization_id. An OIDC provider scoped to an organization lives in the
same ...methods.oidc.config.providers[] array as a plain social provider, so
the inventory finds both. Import the matching ory_organization as well and
replace the generated literal UUID with a reference
(organization_id = ory_organization.<name>.id) so Terraform orders the
create and the destroy correctly. Dropping the attribute clears the link and
leaves the organization with no SSO provider (issue #339).
- Organizations require a B2B plan; event streams require an enterprise
plan. On other plans those endpoints return empty lists or
feature_not_available errors — skip the resources.
- A flat
after hooks array is only importable on some flows. The
auth-method placeholder (_, none, or empty) in an after import ID is
not ignored. The provider resolves it to the default password. That is
harmless only for flows whose hooks are not scoped by auth method, which means
recovery and verification. For login, registration, and settings the
provider always reads
.../flows/<flow>/after/<auth_method>/hooks, so a webhook sitting in the flat
.../after/hooks array on one of those flows cannot be imported at all. Move
it under an auth method in the Console first. The inventory script reports
these instead of emitting a doomed import block.
ory_project.environment must be set explicitly. It is Optional and
Computed with a static default of prod, and it no longer forces
replacement, so it is changed in place. On a stage or dev project,
leaving it out of config plans ~ environment = "dev" -> "prod" and the first
apply really promotes the project's tier, consuming a production slot in the
workspace subscription. Copy the imported project's actual environment into
config before the first apply. home_region still forces replacement, so
that one fails loudly instead.
- Other attributes with static schema defaults (e.g.
cors_enabled) plan a
one-time + <default> change right after import even when unconfigured,
because import leaves them null and the default then materializes. When the
server already holds the default value the apply is a remote no-op; set the
attribute explicitly if you want the plan to say so.
ory_action and ory_project_config share two hook arrays. The
selfservice_flows_login_after_password_hook_require_verified_address,
..._login_after_oidc_..., and the three
selfservice_flows_settings_after_profile_hook_* attributes write into the
same login.after.<method>.hooks and settings.after.profile.hooks arrays
that ory_action read-modify-writes. The inventory script only reports
hook == "web_hook" entries, so the non-webhook hooks those attributes
control are invisible to it. Set them on ory_project_config explicitly if
the Console has them enabled, otherwise they stay unmanaged and an
ory_action delete on the same array can drop them.
ory_social_provider blanks provider keys its schema does not model.
Create and Update replace the whole provider object, so any key Ory stores
that the provider version does not know about is dropped on the next apply.
Use a current provider release before importing social providers, and check
the revision dump for keys that do not appear in the resource schema.
Troubleshooting
project_id forces replacement after import — the project_id in the
resource/provider differs from the imported one. Align them; nothing needs
to be recreated.
- Perpetual diff on a secret attribute — the API returns the value masked
(or not at all). Move it to the write-only
*_wo variant or set the config
value to match what you originally provisioned.
403 feature_not_available — the attribute or resource is gated by a
higher plan (e.g. use_auto_link, event streams). Remove it from config or
have the feature enabled for the project.
- Import of a
before-timing action fails with Invalid Import ID — the
URL's own colons broke the import ID parser in provider versions before the
fix for issue #280. Upgrade the provider; both documented before formats
then work.
Cannot import non-existent remote object — three causes, in order of
likelihood. First, the inventory went stale: the resource was deleted between
running the script and the plan. Remove that import block, or re-run the
script. Second, the project is soft deleted: GET /projects/{id} still
answers HTTP 200 with state = "deleted", so every request succeeds while
nothing can be imported. The script now refuses to run against such a project.
Third, for an ory_action on login, registration, or settings, the
auth-method segment does not match where the hook actually lives. See the flat
after hooks caveat above.
- A resource silently disappears from state on a later plan — the provider
now removes a resource from state instead of erroring when the API reports it
gone, for
ory_project, ory_project_config, ory_workspace,
ory_organization, ory_scim_client, ory_oauth2_client,
ory_oidc_dynamic_client, and ory_trusted_oauth2_jwt_grant_issuer. After onboarding, deleting one of these
in the Console makes the next plan propose a create, not an error. That is
expected; re-apply to restore it, or remove it from config.
429 Too Many Requests during the converge apply — a bulk import of many
ory_oauth2_client or ory_identity resources can exceed the request budget.
The provider retries a 429 with exponential backoff and jitter, 6 times by
default. Raise max_retries on the provider block (maximum 20, or set
ORY_MAX_RETRIES) for a very large project.
value must be one of on an ory_action auth method — profile and
saml are valid auth methods on some flows but were only added in later
provider releases, and the accepted set is now per-flow: login has no
profile, registration has no profile, totp, or lookup_secret, and
settings has no code. Upgrade the provider. Writing an unsupported
flow-and-method pair returns HTTP 200 and is silently discarded, so the
provider warns at plan time instead of failing.
- Generated attribute rejected on plan — delete it; it is computed-only.
-generate-config-out emits every attribute it saw in state, including ones
that are not valid to configure.
Missing Required Attribute / Missing Configuration for Required Attribute on plan (config generation succeeded) — the attribute is
required but the API never returns it, so -generate-config-out left it
null. This hits ory_social_provider.client_secret (per provider type),
ory_trusted_oauth2_jwt_grant_issuer.jwk, and similar. Supply the value in
config (a variable, a *_wo argument, or the literal you originally
provisioned). See the jwk caveat above for the one-time replace it causes.
1---2name: import-existing-project3description: Generate Terraform configuration for an existing Ory Network project. Inventories the project via the Ory Console and Admin APIs, builds terraform import blocks with the correct per-resource import IDs, generates configuration with terraform plan -generate-config-out, and refines it until terraform plan converges to no changes. Use when adopting Terraform for an Ory Network project that was configured through the Console UI, CLI, or API, or when asked to reverse-engineer .tf files from a live project.4---56# Import an existing Ory Network project into Terraform78Onboard a live Ory Network project into Terraform so that `terraform plan`9reports no changes. Terraform's built-in `import` blocks and10`terraform plan -generate-config-out` do the mechanical part; this skill fills11in the Ory-specific knowledge: where each resource is discovered, how each12import ID is built, which attributes to prune from generated config, and which13secrets can never be read back.1415**Safety first:** every step below is read-only against Ory until the final16`terraform apply`, and that apply only records imports (`0 to add, 0 to change,170 to destroy`). Never run `terraform destroy` in the working directory — the18imported resources are real. To undo a mistaken import, use19`terraform state rm <address>`; it removes the resource from state without20touching the live project.2122## Prerequisites2324- Terraform >= 1.5 (`import` blocks and `-generate-config-out`).25- `curl` and `jq` (used by the inventory script).26- A **workspace API key** (`ory_wak_...`, create under Workspace Settings > API27 keys) and, to cover OAuth2 clients / JWKS / trusted issuers, a **project API28 key** (`ory_pat_...`).29- The project UUID. List a workspace's projects with the workspace API key:3031 ```bash32 curl -sH "Authorization: Bearer $ORY_WORKSPACE_API_KEY" \33 "${ORY_CONSOLE_API_URL:-https://api.console.ory.sh}/workspaces/$ORY_WORKSPACE_ID/projects" \34 | jq -r '.projects[] | "\(.id) \(.slug) \(.name)"'35 ```3637 Use this workspace-scoped endpoint, not `GET /projects` (that path needs a38 browser session token and returns 403 for an API key). The `ory list39 projects` CLI also works, but only against Ory's **production** console with40 an interactive `ory auth` session — it ignores `ORY_CONSOLE_API_URL`, so it41 cannot reach other environments, and it rejects `--workspace` when42 `ORY_WORKSPACE_API_KEY` is set. The curl call above is environment-agnostic.4344Export the credentials as environment variables; the provider and the45inventory script both read them:4647```bash48export ORY_WORKSPACE_API_KEY=ory_wak_...49export ORY_PROJECT_API_KEY=ory_pat_... # optional but recommended50export ORY_PROJECT_ID=<project-uuid>51export ORY_PROJECT_SLUG=<project-slug>52```5354Non-default environments (e.g. staging) also need endpoint overrides, both for55the script and the provider block: `ORY_CONSOLE_API_URL` (default56`https://api.console.ory.sh`) and `ORY_PROJECT_API_URL` (a printf template,57default `https://%s.projects.oryapis.com`).5859## Workflow6061### 1. Inventory the project and build import blocks6263Run the bundled script from an empty working directory:6465```bash66<path-to-skill>/scripts/generate-imports.sh > imports.tf67```6869It dumps the project revision (`GET /projects/{id}`), walks every list70endpoint, and emits one `import` block per importable resource with the71correct ID format (see the reference table below). Data-plane objects72(identities, relationship tuples) and unrecoverable ones (project API key73values) come out as explanatory comments instead of import blocks.7475Review `imports.tf` and delete blocks for resources you do not want Terraform76to manage. Everything Terraform imports it will also plan to change or destroy77later, so import deliberately.7879### 2. Scaffold the working directory8081```hcl82# versions.tf83terraform {84 required_providers {85 ory = { source = "ory/ory" }86 }87}8889# provider.tf — keys come from the ORY_* environment variables90provider "ory" {91 project_id = "<project-uuid>"92 project_slug = "<project-slug>"93}94```9596Setting `project_id`/`project_slug` on the provider matters: several import IDs97(social/SAML providers, email templates, single-segment forms) resolve the98project from provider configuration.99100### 3. Generate configuration101102```bash103terraform init104terraform plan -generate-config-out=generated.tf105```106107Expect the "Config generation is experimental" warning; that is fine. If a108resource fails to generate (e.g. conflicting or unknown attributes), remove its109import block, note it, and hand-write that resource later.110111### 4. Refine the generated config112113`generated.tf` is a starting point, not the final code. Apply these rules:114115- **Drop `null` attributes and empty strings/lists** — they are noise and some116 (write-only arguments) are invalid to set explicitly.117- **Drop computed/read-only attributes** the API owns (`id`, timestamps,118 revision IDs, `state`, verification statuses). If Terraform errors with119 "Invalid or unknown key" or "value must be configured only by the provider",120 delete that attribute. Exception: keep `state` on `ory_scim_client`. There121 it is a configurable `enabled`/`disabled` switch that defaults to122 `enabled`, so dropping it re-enables a disabled client on the first apply.123- **Re-supply secrets.** The API never returns SMTP connection URIs, social124 provider client secrets, SCIM client secrets, or OAuth2 client secrets125 (values come back masked or absent). Wire them to `variable` blocks marked `sensitive`, or use the126 write-only variants (`client_secret_wo`, `smtp_connection_uri_wo`, ...) with127 `*_wo_version` to keep them out of state entirely.128 `courier_http_request_config_auth_basic_auth_password`,129 `courier_http_request_config_auth_api_key_value`, and130 `oidc_subject_identifiers_pairwise_salt` behave the same way but have131 **no** `_wo` variant, so they must come from a `sensitive` variable.132 `smtp_connection_uri_wo` is the only write-only argument on133 `ory_project_config`.134- **Populate `ory_project_config` yourself — it generates as an empty shell.**135 The provider intentionally refreshes only attributes already tracked in136 state (so unmanaged settings never drift), and a fresh import tracks137 nothing. `-generate-config-out` therefore emits an all-null block for this138 resource. Delete the null lines and add the attributes you want Terraform139 to own, copying current values from the revision dump (`GET /projects/{id}`140 under `.services.identity.config`, `.services.oauth2.config`,141 `.services.permission.config`, and `.services.account_experience.config`).142 The first plan shows those attributes as additions (`+`) even when the values143 match the server, and the first apply just records them in state because the144 PATCH is idempotent. Prefer the spec-derived attribute names over deprecated145 aliases (run `scripts/migrate-deprecated-attrs.sh` from the provider repo if146 needed).147148 Five attributes do not sit where the dump suggests, so copying by name fails:149150 | Attribute | Where the dump holds it |151 |---|---|152 | `enable_ax_v2` | `.services.account_experience.config.enabled` |153 | `disable_account_experience_welcome_screen` | no service config at all; only `GET /normalized/projects/{id}` |154 | `selfservice_methods_code_config_max_submissions` | reported under `...code.config.max_submissions`, written to `...code.max_submissions` |155 | `oidc_subject_identifiers_pairwise_salt` | a redacted secret; write-only in the provider, so re-supply it from your secret store |156 | `courier_http_request_config_body` | a `https://storage.googleapis.com/.../<sha512>.jsonnet` URL, never the payload |157158 Do **not** copy the `courier_http_request_config_body` URL into config. The159 API rejects a storage URL it did not mint. Set `base64://${base64encode(...)}`160 with the Jsonnet payload instead; the provider resolves the URL back to it by161 comparing the SHA-512 in the URL with the value in state.162- **Replace literal IDs with references** where resources relate, e.g.163 `project_id = ory_project.main.id` instead of the hardcoded UUID.164- **Split into files** (`project.tf`, `oauth2.tf`, `social.tf`, ...) once the165 plan converges.166167### 5. Converge168169```bash170terraform plan171```172173Five `ory_project_config` attributes never converge if you set them empty,174because the server substitutes its own default or refuses to clear the key. If175the dump shows one of these as empty or absent, omit the attribute rather than176writing an empty value, otherwise this step cannot finish:177`account_experience_enabled_locales`,178`selfservice_methods_passkey_config_rp_origins`, `webauthn_rp_origins`,179`selfservice_methods_totp_config_issuer`, and180`selfservice_methods_captcha_config_allowed_domains`.181182Iterate on the config until the plan reports **only imports**:183`Plan: N to import, 0 to add, 0 to change, 0 to destroy.` Then:184185```bash186terraform apply # records the imports in state; changes nothing remotely187terraform plan # must now report: No changes.188```189190If the second plan still shows diffs, fix the config (not the project) and191re-plan. Typical causes are listed under Troubleshooting.192193### 6. Aftercare194195- Keep the `import` blocks in the repo (they are idempotent and document196 provenance) or delete them after the apply — either works.197- Add state hygiene: the state now contains project config and possibly198 private JWKS keys; store it in an encrypted backend, not in git.199- Commit the refined `.tf` files; secrets stay in variables / `*_wo` arguments.200201## Resource reference202203Console API = `https://api.console.ory.sh` with the workspace API key.204Project API = `https://{slug}.projects.oryapis.com` with the project API key.205206| Resource | Discover via | Import ID |207|---|---|---|208| `ory_workspace` | `.workspace_id` on the project payload (the script emits this one commented out) | `{workspace_id}` |209| `ory_project` | `GET /workspaces/{ws}/projects` (console) | `{project_id}` |210| `ory_project_config` | `GET /projects/{id}` (console) | `{project_id}` |211| `ory_custom_domain` | `GET /projects/{id}/cname` (console) | `{project_id}/{domain_id}` |212| `ory_event_stream` | `GET /projects/{id}/eventstreams` (console) | `{project_id}/{stream_id}` |213| `ory_organization` | `GET /projects/{id}/organizations` (console) | `{project_id}/{org_id}` |214| `ory_project_api_key` | `GET /projects/{id}/tokens` (console) | `{project_id}/{key_id}` — value unrecoverable |215| `ory_social_provider` | revision: `.services.identity.config.selfservice.methods.oidc.config.providers[]` | `{provider_id}` (e.g. `google`) |216| `ory_saml_provider` | revision: `...methods.saml.config.providers[]` | `{provider_id}` |217| `ory_scim_client` | `GET /normalized/projects/{id}` (console): `.current_revision.scim_clients[]` | `{project_id}/{client_id}`, secret unrecoverable |218| `ory_action` | revision: `...selfservice.flows.<flow>.<timing>...hooks[]` where `hook == "web_hook"` | after: `{project_id}:{flow}:after:{auth_method}:{METHOD}:{url}`; before: `{project_id}:{flow}:before:{METHOD}:{url}` |219| `ory_email_template` | revision: `...courier.templates.<base>.<valid\|invalid>.email` with non-empty `subject` or `body.html` / `body.plaintext` | `{base}_{valid\|invalid}` (e.g. `recovery_code_valid`) |220| `ory_oauth2_client` | `GET /admin/clients` (project) | `{client_id}` |221| `ory_oidc_dynamic_client` | same list; `GET /admin/clients` carries no field that distinguishes a DCR client, so pick them out yourself | `{client_id}` (identical to `ory_oauth2_client`, so switching resource type is a one-word edit) |222| `ory_json_web_key_set` | `GET /admin/keys/{set}` (project); no list-all endpoint, set IDs must be known | `{project_id}/{set_id}` |223| `ory_trusted_oauth2_jwt_grant_issuer` | `GET /admin/trust/grants/jwt-bearer/issuers` (project) | `{grant_id}` |224| `ory_identity` | `GET /admin/identities` (project) | `{identity_id}` |225| `ory_relationship` | revision: `.services.permission.config.namespaces[]` + `GET /relation-tuples?namespace=` (project) | `namespace:object#relation@subject_id`, or a subject set `namespace:object#relation@subject_ns:subject_obj#subject_rel` |226| `ory_identity_schema` | revision: `...identity.schemas[]`, plus `GET /identity-schemas` (console) for workspace-scoped schemas the revision omits | **not importable** (immutable by design) |227228Single-segment forms (`{domain_id}`, `{org_id}`, `{key_id}`, `{stream_id}`,229`{set_id}`) also work when the provider block sets `project_id`; prefer the230explicit `{project_id}/...` form in generated files.231232## Caveats233234- **Identity schemas cannot be imported.** They are immutable; leave existing235 schemas unmanaged and manage only newly created schemas. Read a single schema236 with the `ory_identity_schema` data source, or list every schema the project237 can see with `ory_identity_schemas`. The project revision lists only schemas238 explicitly added to the project, so the plural data source and the console239 `GET /identity-schemas` endpoint see workspace-scoped schemas the revision240 omits.241- **Secrets never round-trip.** SMTP connection URI, pairwise subject242 identifier salt, social/SAML client secrets, SCIM client secrets, OAuth2243 client secrets, tokenizer template keys:244 re-supply via variables or write-only `*_wo` arguments. Until you do, some of245 these show a perpetual diff or import as empty.246- **`ory_scim_client` imports with an empty secret and a stored mapper URL.**247 The API redacts `authorization_header_secret` in every response, and it248 rewrites `mapper_url` into a content-addressed object-storage URL. Set both249 in config after the import: the first apply uploads the mapper and rotates250 the secret to the configured value, in place. Also import the matching251 `ory_organization` and reference it as252 `organization_id = ory_organization.<name>.id`, because deleting an253 organization deletes its SCIM clients server-side.254- **`ory_project_api_key` values** exist only at creation time. Importing one255 yields a resource whose `value` is null; rotating it through Terraform means256 destroy + create (a brand-new key).257- **`ory_trusted_oauth2_jwt_grant_issuer.jwk` forces a one-time replace on258 import.** The public-key `jwk` is required, forces replacement when changed,259 and is never returned by the read endpoint, so import leaves it empty. Supply260 the issuer's original public JWK in config; the first apply then destroys and261 re-creates the trust once to store it (a functional no-op re-registration —262 the issuer/subject/scope are unchanged). Plans after that first apply are263 clean. If a transient re-registration is unacceptable, leave the issuer264 unmanaged.265- **Default `hydra.*` JWKS sets** (`hydra.openid.id-token`,266 `hydra.jwt.access-token`) are system-managed — do not import them. Importing267 any JWKS puts private key material into state.268- **Identities and relationship tuples are data, not configuration.** Import269 only the handful that are genuinely config-like (service accounts, seed270 tuples).271- **`ory_social_provider.auto_link`** is write-only in the API; an import will272 not populate it. Re-add it to config manually where used (enterprise273 feature).274- **B2B SSO providers come back as `ory_social_provider` with an275 `organization_id`.** An OIDC provider scoped to an organization lives in the276 same `...methods.oidc.config.providers[]` array as a plain social provider, so277 the inventory finds both. Import the matching `ory_organization` as well and278 replace the generated literal UUID with a reference279 (`organization_id = ory_organization.<name>.id`) so Terraform orders the280 create and the destroy correctly. Dropping the attribute clears the link and281 leaves the organization with no SSO provider (issue #339).282- **Organizations require a B2B plan; event streams require an enterprise283 plan.** On other plans those endpoints return empty lists or284 `feature_not_available` errors — skip the resources.285- **A flat `after` hooks array is only importable on some flows.** The286 auth-method placeholder (`_`, `none`, or empty) in an `after` import ID is287 **not** ignored. The provider resolves it to the default `password`. That is288 harmless only for flows whose hooks are not scoped by auth method, which means289 recovery and verification. For `login`, `registration`, and `settings` the290 provider always reads291 `.../flows/<flow>/after/<auth_method>/hooks`, so a webhook sitting in the flat292 `.../after/hooks` array on one of those flows cannot be imported at all. Move293 it under an auth method in the Console first. The inventory script reports294 these instead of emitting a doomed import block.295- **`ory_project.environment` must be set explicitly.** It is `Optional` and296 `Computed` with a static default of `prod`, and it no longer forces297 replacement, so it is changed **in place**. On a `stage` or `dev` project,298 leaving it out of config plans `~ environment = "dev" -> "prod"` and the first299 apply really promotes the project's tier, consuming a production slot in the300 workspace subscription. Copy the imported project's actual environment into301 config before the first apply. `home_region` still forces replacement, so302 that one fails loudly instead.303- **Other attributes with static schema defaults** (e.g. `cors_enabled`) plan a304 one-time `+ <default>` change right after import even when unconfigured,305 because import leaves them null and the default then materializes. When the306 server already holds the default value the apply is a remote no-op; set the307 attribute explicitly if you want the plan to say so.308- **`ory_action` and `ory_project_config` share two hook arrays.** The309 `selfservice_flows_login_after_password_hook_require_verified_address`,310 `..._login_after_oidc_...`, and the three311 `selfservice_flows_settings_after_profile_hook_*` attributes write into the312 same `login.after.<method>.hooks` and `settings.after.profile.hooks` arrays313 that `ory_action` read-modify-writes. The inventory script only reports314 `hook == "web_hook"` entries, so the non-webhook hooks those attributes315 control are invisible to it. Set them on `ory_project_config` explicitly if316 the Console has them enabled, otherwise they stay unmanaged and an317 `ory_action` delete on the same array can drop them.318- **`ory_social_provider` blanks provider keys its schema does not model.**319 Create and Update replace the whole provider object, so any key Ory stores320 that the provider version does not know about is dropped on the next apply.321 Use a current provider release before importing social providers, and check322 the revision dump for keys that do not appear in the resource schema.323324## Troubleshooting325326- **`project_id forces replacement` after import** — the `project_id` in the327 resource/provider differs from the imported one. Align them; nothing needs328 to be recreated.329- **Perpetual diff on a secret attribute** — the API returns the value masked330 (or not at all). Move it to the write-only `*_wo` variant or set the config331 value to match what you originally provisioned.332- **`403 feature_not_available`** — the attribute or resource is gated by a333 higher plan (e.g. `use_auto_link`, event streams). Remove it from config or334 have the feature enabled for the project.335- **Import of a `before`-timing action fails with `Invalid Import ID`** — the336 URL's own colons broke the import ID parser in provider versions before the337 fix for issue #280. Upgrade the provider; both documented before formats338 then work.339- **`Cannot import non-existent remote object`** — three causes, in order of340 likelihood. First, the inventory went stale: the resource was deleted between341 running the script and the plan. Remove that import block, or re-run the342 script. Second, the project is **soft deleted**: `GET /projects/{id}` still343 answers HTTP 200 with `state = "deleted"`, so every request succeeds while344 nothing can be imported. The script now refuses to run against such a project.345 Third, for an `ory_action` on `login`, `registration`, or `settings`, the346 auth-method segment does not match where the hook actually lives. See the flat347 `after` hooks caveat above.348- **A resource silently disappears from state on a later plan** — the provider349 now removes a resource from state instead of erroring when the API reports it350 gone, for `ory_project`, `ory_project_config`, `ory_workspace`,351 `ory_organization`, `ory_scim_client`, `ory_oauth2_client`,352 `ory_oidc_dynamic_client`, and `ory_trusted_oauth2_jwt_grant_issuer`. After onboarding, deleting one of these353 in the Console makes the next plan propose a **create**, not an error. That is354 expected; re-apply to restore it, or remove it from config.355- **`429 Too Many Requests` during the converge apply** — a bulk import of many356 `ory_oauth2_client` or `ory_identity` resources can exceed the request budget.357 The provider retries a 429 with exponential backoff and jitter, 6 times by358 default. Raise `max_retries` on the provider block (maximum 20, or set359 `ORY_MAX_RETRIES`) for a very large project.360- **`value must be one of` on an `ory_action` auth method** — `profile` and361 `saml` are valid auth methods on some flows but were only added in later362 provider releases, and the accepted set is now per-flow: `login` has no363 `profile`, `registration` has no `profile`, `totp`, or `lookup_secret`, and364 `settings` has no `code`. Upgrade the provider. Writing an unsupported365 flow-and-method pair returns HTTP 200 and is silently discarded, so the366 provider warns at plan time instead of failing.367- **Generated attribute rejected on plan** — delete it; it is computed-only.368 `-generate-config-out` emits every attribute it saw in state, including ones369 that are not valid to configure.370- **`Missing Required Attribute` / `Missing Configuration for Required371 Attribute` on plan (config generation succeeded)** — the attribute is372 required but the API never returns it, so `-generate-config-out` left it373 null. This hits `ory_social_provider.client_secret` (per provider type),374 `ory_trusted_oauth2_jwt_grant_issuer.jwk`, and similar. Supply the value in375 config (a `variable`, a `*_wo` argument, or the literal you originally376 provisioned). See the `jwk` caveat above for the one-time replace it causes.