Migrate from Terraform
Adopt the Authentik objects an existing Terraform module manages into weebo-authentik CRDs, without recreating or disrupting them.
This operator was built to replace a hand-maintained Terraform module.
The crates/importer tool reads your live Authentik instance and
emits ready-to-adopt CR YAML, so the objects Terraform created keep
their identity and are taken over in place — never destroyed and
recreated.
How adoption works (read this first)
The reconcilers never adopt an existing Authentik object by matching on
its name. A CR takes over a pre-existing object in exactly one way: its
status.authentikId is already set to that object's Authentik primary
key. The importer pre-populates status.authentikId on every CR it
generates — that is the whole point of the tool.
kubectl apply -f silently drops status. Kubernetes strips the
status subresource on create/update, so applying the generated files is
only half the job. Let the importer do it (--apply, step 4): each CR is
created paused (authentik.weebo.io/import-pending), its
status.authentikId seeded, then released to the operator. If you apply
the files yourself instead, patch each object's status subresource
separately. If you skip it, the first reconcile sees no
authentikId, tries attempt-create-first, and collides with the very
object you meant to adopt (a AuthentikObjectAlreadyExists error). The
collision is non-destructive — attempt-create-first never overwrites or
deletes the existing object — but the CR stays Errored until you supply
the id.
Prerequisites
- The operator is installed and healthy (Release to your
cluster) and an
AuthentikInstanceis connected (Connect an AuthentikInstance). The importer does not create theAuthentikInstance— it's user-authored — but its generatedAuthentikApplications reference one by name via--instance-ref. - A read-access Authentik API token and network reachability to the Authentik API from wherever you run the importer.
- A Rust toolchain to run the importer. It is a one-shot migration
tool and is not shipped in the operator image — run it from a
checkout with
cargo run -p importer(this repo usesmiseto provide the toolchain;mise installthen the command below). kubectl1.24+ (for--subresource=status) andyqfor the status-patch loop.
Migrate namespace by namespace
AuthentikApplication/AuthentikAccessPolicy are namespaced, so
migration is done one target namespace at a time (matching the Terraform
module's per-team layout). Cluster-scoped kinds
(AuthentikGroup/AuthentikUser/AuthentikBrand/AuthentikOutpost) are
regenerated on every run regardless of --namespace — import them
once and reuse.
1. Run the importer
AUTHENTIK_TOKEN=<read-token> cargo run -p importer -- \
--authentik-url https://login.example.com \
--instance-ref main \
--namespace team-a \
--out import-output/team-a| Flag | Meaning |
|---|---|
--authentik-url | Base web URL of the Authentik instance to read from — same shape as AuthentikInstance.spec.url; /api/v3 is appended for you. |
--authentik-token | API token (or the AUTHENTIK_TOKEN env var, as above). |
--instance-ref | Name of the AuthentikInstance CR generated AuthentikApplications should point at (spec.instanceRef). |
--namespace | Namespace stamped on every generated AuthentikApplication/AuthentikAccessPolicy. |
--applications | Optional comma-separated slug allow-list; defaults to every application. |
--out | Output directory — one YAML file per generated CR (default import-output). |
The tool writes one <kind>-<name>.yaml per object and prints a
reminder about the status-patch step below.
2. Review the generated CRs
Skim the output before applying anything. A few expected behaviors:
metadata.nameis slugified to a valid RFC 1123 name (lowercased, non-alphanumerics collapsed to-), whilespec.name/spec.username/ domains keep the exact original value. Names that slugify to the same string will collide — rename in that case.- Every file carries a
status.authentikId. That is intentional (it's what step 4 applies). AuthentikBrand.spec.defaultApplicationRefis deliberately left unset — that cross-namespace reference isn't resolved by the operator yet. Wire it by hand later if you relied on it.
3. Clear the admission webhook for the target namespace
The webhook enforces a namespace allow-list for
AuthentikApplication/AuthentikAccessPolicy, defaulting to deny
once any AuthentikNamespacePolicy exists in the cluster. Two cases:
-
No
AuthentikNamespacePolicyanywhere yet → everything is allowed; you can apply the imported Applications directly. (Add policies after migration; see the allow-list guide.) -
Policies already exist → the target namespace must be allow-listed first, or the webhook will reject the create:
apiVersion: authentik.weebo.io/v1alpha1 kind: AuthentikNamespacePolicy # cluster-scoped — no metadata.namespace metadata: name: allow-team-a spec: rules: - namespaces: ["team-a"] allowedKinds: ["AuthentikApplication", "AuthentikAccessPolicy"] effect: AllowSee the allow-list guide for the full rule semantics (deny-wins, default-deny).
Cluster-scoped kinds (Group/User/Brand/Outpost/Instance) are not gated by the webhook, so they can be applied at any time.
4. Adopt the CRs
Re-run the importer with --apply (same arguments, current kube context):
it writes the files again, then adopts each CR — groups first, namespaced
kinds last — creating it paused (authentik.weebo.io/import-pending, the
controllers leave it alone), seeding its status.authentikId, and only
then releasing it. The operator therefore never sees a CR without the id
of the object it stands for: no AuthentikObjectAlreadyExists, no
duplicate.
importer --authentik-url https://login.example.com --instance-ref main \
--namespace team-a --out import-output/team-a --apply
# Wrote CRDs to import-output/team-a and adopted them: 42 adopted, 0 already bound, 0 failed.It is safe to re-run: a CR already bound to its object is skipped, one
left paused by an interrupted run is finished. A CR that already exists
in the cluster but is bound to nothing yet is refused — the operator may
be creating its object right now; delete it, or pass --force to take it
over.
Without --apply, kubectl apply -f the files, then re-attach each id
with kubectl patch <kind> <name> --subresource=status --type merge -p '{"status":{"authentikId":"<id>"}}' before the first reconcile — a
reconcile in between reports a harmless, non-destructive
AuthentikObjectAlreadyExists until the id is there.
5. Verify adoption
# Every managed object should reach Ready=True and carry the id you patched:
kubectl get authentikgroups,authentikusers,authentikbrands,authentikoutposts -A \
-o custom-columns=KIND:.kind,NAME:.metadata.name,ID:.status.authentikId,READY:.status.conditions[0].status
kubectl -n team-a get authentikapplications,authentikaccesspolicies \
-o custom-columns=NAME:.metadata.name,ID:.status.authentikId,READY:.status.conditions[0].statusConfirm each object is Ready=True with the expected authentikId, and
spot-check in the Authentik admin UI that nothing was duplicated or
recreated. Repeat steps 1-5 for the next namespace.
Decommission Terraform (do not destroy)
Once the operator is confirmed authoritative and at parity, stop Terraform from managing those objects without deleting them:
Never run terraform destroy (or terraform apply after deleting the
resources from config) against the objects you just adopted — that would
delete the real Authentik objects the CRs now own. Use
terraform state rm so Terraform forgets them instead.
-
Freeze Terraform for the migrated scope while you run the import and verification (pause its CI/CD apply), so the two systems can't fight over the same objects mid-migration.
-
For each adopted object, remove it from Terraform state:
terraform state rm 'authentik_group.team_a' terraform state rm 'authentik_application.dashboard' # ...one per adopted resource -
Delete the corresponding resource blocks from the
.tffiles and runterraform plan— it should show no changes (Terraform no longer knows about them, the operator owns them now). -
Once every namespace is migrated and
terraform planis clean, retire the module.
Caveats and limitations
- Importer is source-only. It isn't in the operator image; run it via
cargo run -p importerfrom a checkout. - Cluster-scoped kinds regenerate each run. Group/User/Brand/Outpost
are emitted on every invocation regardless of
--namespace— import them once; re-running is idempotent as long as you don't re-applystale copies. defaultApplicationRefon brands is not resolved by the operator yet, so the importer leaves it unset.- Single instance for cluster-scoped kinds. Group/User/Brand/Outpost
resolve against the one
AuthentikInstancein the cluster and error (AmbiguousDefaultInstance) if there's more than one — see Connect an AuthentikInstance → Multiple instances. - Rollback. Nothing here deletes Authentik objects, so a botched
import is recoverable: fix the CRs (or
kubectl deletethem before their finalizers matter — while Terraform still owns the objects), and re-run. Keep Terraform as the source of truth until step 5 passes.
Known migration blockers
Three gaps surfaced while planning the rollout. The migration order
harbor → s3 → argo → che → vault is chosen deliberately around them:
harbor and s3 have no in-repo consumer of AUTHENTIK_URL, so they prove
the Vault plumbing end-to-end with zero consumer changes before any of
the below matter.
1. Per-app AUTHENTIK_URL issuer — fixed
Previously the operator wrote AUTHENTIK_URL as the instance base URL
(configuration.base_path), not the per-application OIDC issuer. Only two
consumers read that key — argocd's oidc.config issuer (auth-secret.yaml)
and 1.pulu-init/Taskfile.yaml's OIDC_ISSUER_URL — so the order above
sequences them (argo, che) after the consumer-free apps.
The operator now writes the real per-app issuer
<web-base>/application/o/<application-slug>/. If you adopted apps under
an earlier build, re-reconcile them (any spec touch) so the corrected
AUTHENTIK_URL is rewritten to their secret targets before argo/che cut
over.
2. Vault Kubernetes-auth role must bind the operator's SA — open (external)
The operator's ServiceAccount is authentik-weebo-authentik, but the
auth Vault role only binds ["authentik", "default"]. Until the role
also lists the operator SA, its Kubernetes-auth login is rejected. This
lives in a different module and nothing needs it until the vault
phase, so it is documented here, not applied. The exact hunk in
2.terra/vault/terra-map/auth-base.tf:
resource "vault_kubernetes_auth_backend_role" "authentik" {
# ...
bound_service_account_names = [
"authentik",
"default",
+ "authentik-weebo-authentik",
]
}3. Selecting a Vault CA cert — fixed
The operator's Vault client was built from the address alone (no
caProvider, no skip-verify), and the chart exposes extraEnv but no
extraVolumes, so there was no clean way to point $VAULT_CACERT at
openbao-tls. Rather than depend on podAnnotations +
bank-vaults inject-certs (a route that was
never confirmed — verify before relying on it), the operator now
reads the CA PEM directly from a Secret via
secretStore.vault.caSecretRef — see
Connect an AuthentikInstance → Trusting a private Vault CA.
No mounted volume or VAULT_CACERT wiring required.