Ich werde eine kleine Behauptung aufstellen und sie dann belegen. Kein
URL-Shortener liefert derzeit einen erstklassigen Terraform-Provider aus.
Bitly, TinyURL, Rebrandly, Short.io, Dub.co - alle fünf veröffentlichen
REST-APIs, mehrere veröffentlichen Webhooks, keiner veröffentlicht einen
terraform-provider-*. Ein Community-Provider für Bitlys v3-API existiert
auf GitHub; er wird nicht gepflegt und deckt vielleicht ein Viertel der
API-Oberfläche ab. Das ist die Lücke.
Vor ein paar Wochen haben wir uns hingesetzt, um sie zu schließen. Das
Ergebnis ist terraform-provider-elido, den die Elido-API heute als
elido_link (Resource), elido_workspace (Data Source) und
elido_custom_domain (Data Source vorerst - lies weiter) verfügbar
macht. Was folgt, ist eine Tour durch das, was ausgeliefert wurde, die
Engineering-Entscheidungen dahinter und die Teile, die wir in v0.1.0
bewusst nicht ausgeliefert haben. Der Provider ist Open Source unter
derselben Lizenz wie der Rest von Elido und liegt unter
tools/terraform-provider-elido/.
Warum Short Links zu Terraform gehören#
Das Argument ist kurz. Wenn du Marketing-Redirects betreibst, hast du bereits andere Infrastrukturkomponenten, die auf dieselbe Kampagne konvergieren:
- Einen Cloudflare-DNS-Eintrag, der auf eine Lander-Seite zeigt.
- Einen S3-Bucket und eine CloudFront-Distribution, die diese Lander-Seite ausliefern.
- Eine Lambda- oder Cloud-Run-Service, die signierte URLs erzeugt.
- Ein Kampagnen-Tag, das in Google Tag Manager oder Segment eingebrannt ist.
Alle fünf werden 2026 als Terraform verwaltet. Der Short Link, der ganz vorne im Funnel sitzt - der tatsächliche Einstiegspunkt, auf den ein Nutzer klickt - liegt in einem Google Doc. Diese Lücke ist, wo Drift herkommt. Eine Lander-Seite wird deprecated und der Redirect, der darauf zeigt, lebt weiter, sammelt 404er, bis jemand das Marketing-Team auf Slack anpingt.
Du kannst diese Lücke auf zwei Wegen schließen. Du kannst ein
Klebescript in TypeScript schreiben, das zwischen deiner Terraform-Ausgabe
und unserer REST-API sitzt. Das funktioniert; wir haben Kunden, die genau
das tun. Oder wir geben dir einen echten Terraform-Provider, in dem der
Redirect ein resource-Block neben deinem Cloudflare-Eintrag ist, und
terraform plan / terraform destroy wissen davon genauso wie von allem
anderen. Wir haben den zweiten Weg gewählt. Der erste lag schon bei dir.
Was terraform-provider-elido heute tut#
Die minimale v0.1.0-Oberfläche in HCL:
terraform {
required_providers {
elido = {
source = "elidoapp/elido"
version = "~> 0.1"
}
}
}
provider "elido" {
# api_url defaults to https://api.elido.app
# api_token reads ELIDO_API_TOKEN
}
data "elido_workspace" "main" {
id = 42
}
data "elido_custom_domain" "links" {
workspace_id = data.elido_workspace.main.id
hostname = "links.example.com"
}
resource "elido_link" "spring_campaign" {
workspace_id = data.elido_workspace.main.id
domain_id = data.elido_custom_domain.links.id
slug = "spring-2026"
destination_url = "https://example.com/landing/spring"
title = "Spring 2026 email campaign"
tags = ["spring-2026", "email"]
redirect_status = 301
}
terraform apply und du bist fertig. Drift-Erkennung funktioniert auf
den Feldern, die die API zurückspiegelt. Das Umbenennen des
Terraform-Resource-Labels oder das Ändern des slug im laufenden Betrieb
erzwingt kein Replacement - der Provider sendet ein PATCH gegen dieselbe
numerische ID. Das Ändern von workspace_id oder domain_id erzwingt
ein Replacement, denn an diesem Punkt sprichst du über eine andere
Edge-Route. Das ist der Common-Sense-Lebenszyklus, und das ist es,
worauf die
Plugin-Framework-Anleitungen
von HashiCorp dich hindrängen.
Die Bulk-Rollout-Form ist der Teil, der die Arbeit für die meisten Teams rechtfertigt:
locals {
channels = ["email", "twitter", "linkedin", "reddit", "hn"]
regions = ["us", "eu", "apac", "latam"]
}
resource "elido_link" "campaign_launch" {
for_each = {
for pair in setproduct(local.channels, local.regions) :
"${pair[0]}-${pair[1]}" => pair
}
workspace_id = data.elido_workspace.main.id
domain_id = data.elido_custom_domain.links.id
slug = "launch-${each.key}"
destination_url = "https://example.com/launch?ch=${each.value[0]}&r=${each.value[1]}"
tags = ["launch-2026", each.value[0], each.value[1]]
}
Zwanzig Links, ein Apply. Lösche den Block, zwanzig Löschungen, ein Apply. Das ist ungefähr der Use Case, der im letzten Quartal in drei Kundengesprächs-Notizen aufgetaucht ist: Marketing will pro-Kanal-pro-Region UTM-Links für einen Launch, Engineering baut jedes Mal ein Sheets-to-API-Script, das Script veraltet, der Autor des Scripts verlässt das Unternehmen. Terraforms Stärke hier ist nicht die Neuheit
- es ist, dass wir das Muster langweilig gemacht haben.
Die vollständige Anleitung mit Attribut-Referenz und Import-Beispielen
findest du unter /docs/guides/terraform. Die
Provider-Quelle liefert eine examples/main.tf mit, die eine
ausführlichere Version des Snippets oben enthält.
Wie der Provider gebaut ist#
Etwa 600 Zeilen Go, davon ~200 Schema-Definitionen. Die Form:
tools/terraform-provider-elido/
├── main.go # plugin entrypoint
├── internal/provider/
│ ├── provider.go # config + auth
│ ├── link_resource.go # CRUD + import
│ ├── workspace_data_source.go # GET /v1/workspaces/{id}
│ ├── custom_domain_data_source.go # GET /v1/workspaces/{id}/domains
│ ├── helpers.go # tag conversion
│ └── provider_test.go # 7 unit tests
├── go.mod # depends on packages/sdk-go
├── .goreleaser.yml # signed-checksum builds
├── terraform-registry-manifest.json # protocol_versions: ["6.0"]
├── Makefile # build + install-local + testacc
└── examples/main.tf
Ein paar Entscheidungen, die es wert sind, hervorgehoben zu werden.
Wir nutzen das Plugin-Framework, nicht das Legacy-SDK. HashiCorp
hat neue Provider 2023 explizit zu
terraform-plugin-framework
gelenkt. Die meisten beliebten Provider (aws, cloudflare, google)
befinden sich mitten in der Migration; die kleineren, neueren sind
framework-nativ. Auf der grünen Wiese mit dem Legacy-SDK zu bauen,
hätte bedeutet, eine Migrationsaufgabe in dem Moment zu übernehmen,
in dem wir ausgeliefert haben. Wir haben die Migration vermieden,
indem wir keine erzeugt haben. Das Framework hat ein strengeres
Typsystem, echte Schema-Validierung auf der Plugin-Protokoll-Ebene
und ein viel saubereres Plan-Modell (PlanModifiers statt
CustomizeDiff-Callbacks). Für einen kleinen Provider ist die
Ergonomie-Lücke groß.
Der Provider dupliziert das SDK nicht. Jede Resource-Methode
delegiert an packages/sdk-go,
welches dasselbe SDK ist, das wir für reine Go-Integrationen
veröffentlichen. Der Provider ist by-design ein dünner
Schema-zu-SDK-Adapter. Das hat zwei Konsequenzen. Die gute: Jeder
Bug, den wir im SDK fixen, landet kostenlos im Provider. Die
schlechte: Jede Lücke im SDK ist eine Lücke im Provider. Das ehrliche
Beispiel sind Custom Domains. api-core macht POST/DELETE für
/v1/workspaces/{id}/domains noch nicht verfügbar; der Schreibpfad
liegt im domain-manager hinter dem Dashboard. Bis api-core die
Writes durchreicht, hat das SDK kein Domains.Create, und der
Provider hat keine elido_custom_domain-Resource - nur eine
Data Source, die eine existierende per Hostname nachschlägt. Wir
werden diese Lücke in v0.2.0 schließen; der Proxy-Shim ist eine
Sub-Wochen-Änderung und der SDK- + Provider-PR ist bereits
entworfen.
Auth ist genauso geformt wie bei jedem anderen Elido-Client.
Bearer-API-Key im Authorization-Header, mit Fallback auf
ELIDO_API_TOKEN in der Umgebung. Wir geben weder Cookie-Auth noch
X-Dev-User-ID im Provider frei; das sind lokale
Entwicklungsbequemlichkeiten, die in IaC, wo die Konfiguration in
der Versionskontrolle liegt und in CI läuft, nichts zu suchen haben.
Deine CI hat entweder ein Token oder nicht.
Drift-Erkennung: der Teil, der schwerer ist, als es aussieht#
Wenn du an den offensichtlichen Teilen vorbeigelesen hast, ist das
der Abschnitt, der das Lesen wert ist. Terraform-Diffing ist
grundsätzlich eine Frage von: Gegeben, was der Nutzer geschrieben
hat (Plan), und was der Server beim letzten Mal zurückgegeben hat
(State), und was der Server jetzt zurückgibt (Read), was sollten
wir vorschlagen zu tun?
Für eine Resource wie elido_link machen drei Dinge das nichttrivial:
Optional + Computed-Felder mit Server-Defaults. Der Nutzer kann
redirect_status weglassen. Der Server füllt 302 ein. Das nächste
Read gibt 302 zurück. Ohne Vorsicht sieht das wie Drift bei
jedem Plan aus - "Ich habe um nichts gebeten, ich habe 302 zurück
bekommen, schlage vor, es wieder auf nichts zu setzen". Das Framework
gibt dir einen UseStateForUnknown-Plan-Modifier, der sagt: "Wenn
ich keinen geplanten Wert habe, behalte das, was im State ist."
Wir verwenden ihn auf jedem server-default-gesetzten Feld. Das
klingt trivial; es ist die Quelle der häufigsten Provider-Bugs im
Ökosystem ("provider produced inconsistent result after apply").
Tags mit serverseitiger Normalisierung. Unsere API speichert
Tags als Set; Terraform sieht sie als geordnete Liste. Aktuell
weichen wir hier aus. Der Server bewahrt die Reihenfolge beim
Echo, also ist der Diff in der Praxis stabil, aber ein Nutzer,
der Tags in HCL umordnet, sieht ein No-op-Update. Das ist korrektes
Verhalten; die Alternative - beim Input still zu sortieren - würde
bedeuten, dass terraform plan und terraform apply sich darüber
uneinig sind, was sich ändert, was die kardinale Terraform-Sünde
ist. Wir werden das überdenken, wenn echte Kunden sich beschweren.
Der HashiCorp-Leitfaden zu
Best Practices
steht hier fest auf der "Tu nichts Überraschendes"-Seite.
Status als Tri-State. Ein Link kann active, paused oder
archived sein. status = "paused" in HCL zu setzen, aber nicht
beim Create (der Server defaultet auf active), bedeutet, dass wir
ein Follow-up-PATCH innerhalb desselben Create absetzen müssen.
Das ist als Post-Create-Reconciliation-Schritt implementiert - hab
das im Hinterkopf, falls du die Quelle liest. Die Alternative -
Status als separate Resource (elido_link_status mit link_id
als Schlüssel) zu exponieren - ist das, was der AWS-Provider für
einige Resources tut. Wir haben es in Betracht gezogen; für ein
optionales Feld überwiegen die Kosten den Nutzen. Wenn wir einen
zweiten Post-Create-Knopf hinzufügen, denken wir um.
Import. terraform import elido_link.spring_campaign 42:7 -
das ist <workspace_id>:<link_id>. Wir wählen die
durch-Doppelpunkt-getrennte Form, weil der ImportState-Callback
des Frameworks dir einen einzelnen String gibt und du ihn selbst
parst. Die <id>:<id>-Form ist üblich bei Providern, die Resources
durch ein Tupel verschlüsseln - siehe die
Import-Dokumentation von google_compute_instance
für die kanonische Referenz. Wir sind bewusst dabei, den
menschenlesbaren slug nicht zu überladen; der Resource-State ist
mit der numerischen ID verschlüsselt, und das ist das Einzige, was
du in einen Import schreiben solltest.
Tests, CI, das Registry#
Die Unit-Suite (heute 7 Tests) deckt die Schema-Validierungsschicht
plus die reinen Funktions-Helper ab - splitImportID, linkToModel,
apiErrorString, optString. Sie läuft in 0,5 Sekunden und gated
jeden PR durch dieselbe go-Matrix, die unsere 13 Services baut.
Es gibt auch ein testacc-Target, das gegen ein lebendiges
api-core läuft, wenn TF_ACC=1 gesetzt ist, aber das ist opt-in:
Es benötigt ein Token, und wir führen es nicht bei jedem Commit
aus, weil jeder Test einen echten Link erstellt und löscht. Das
Testing-Framework
von HashiCorp dokumentiert das Muster; wir weichen nicht davon ab.
Die Release-Pipeline ist mit goreleaser verdrahtet, mit der
exakten Build-Matrix, die das Terraform Registry erwartet: linux,
darwin, freebsd, windows × amd64/arm64 (plus arm und
386 auf Linux), SHA256SUMS über die Archive, GPG-Signatur auf
den SHA256SUMS und eine terraform-registry-manifest.json, die
protocol_versions: ["6.0"] deklariert. Tagge einen Commit
terraform-provider-vX.Y.Z, der GitHub-Actions-Workflow führt
goreleaser release --clean aus, und der GitHub-Release geht
live. Das
Terraform Registry
pollt den Release nach seinem eigenen Zeitplan und nimmt die
Version auf. Das Einzige, was aktuell fehlt, ist der GPG-Key - wir
prägen diese Woche einen, der dediziert für Provider-Releases ist,
was bedeutet, dass v0.1.0 etwa zur gleichen Zeit wie dieser
Post im Registry landet.
Installiere in der Zwischenzeit über dev_overrides in
~/.terraformrc:
provider_installation {
dev_overrides {
"elidoapp/elido" = "/Users/<you>/.terraform.d/plugins/elidoapp/elido"
}
direct {}
}
Dann make install-local aus tools/terraform-provider-elido/,
und terraform plan löst die Binary direkt ohne terraform init
auf. Das ist das offizielle HashiCorp-Muster für die
Provider-Entwicklung und funktioniert genauso gut als Übergangs-
Installationspfad bis v1.0.0.
Was bewusst nicht in v0.1.0 ist#
Drei Dinge, die wir in Betracht gezogen, nicht ausgeliefert haben und hervorheben wollen, damit niemand überrascht ist.
Keine elido_custom_domain als Resource. Oben besprochen. Die
Data Source reicht aus, um domain_id in elido_link zu verketten,
was der tragende Use Case ist; das Full-Lifecycle-Management wartet
auf api-core. ETA: v0.2.0, Mitte 2026.
Keine elido_folder, kein elido_api_key. Das SDK hat beide;
wir haben uns dagegen entschieden, Schemas in v0.1.0 hinzuzufügen,
weil ihre Lebenszyklen nicht dort sind, wo der Kundenschmerz ist.
Ordner sind organisatorische Metadaten; API-Keys werden typischerweise
einmal ausgegeben und über das Dashboard rotiert. Wir fügen sie
hinzu, wenn jemand fragt.
Keine Code-Generierung aus der OpenAPI-Spezifikation. HashiCorp
liefert
terraform-plugin-codegen-openapi
als Beta-Tool aus. Wir haben es mit unserer Spezifikation probiert;
die generierten Schemas sind mittelmäßig - jedes Nullable-Feld wird
zu Optional + Computed, jede Liste wird zu einem Set, das
Ergebnis erfordert genauso viel Nachbesserung wie ein
handgeschriebenes Schema und ist schwerer weiterzuentwickeln. Mit
drei Resources auf dem Tisch gewinnt das Handgeschriebene. Wir
werden den Generator in sechs Monaten erneut prüfen, wenn mehr
unserer Peers ihn kampferprobt haben.
Was kaputtging, während wir ihn bauten#
Drei Dinge, die wir im ersten Durchgang falsch gemacht haben.
Das erste war State auf Optional + Computed. Wir haben title
zunächst als einfachen Optional-String modelliert. Kunden, die
es aus HCL weggelassen haben, bekamen einen sauberen Create - und
dann schlug jedes nachfolgende terraform plan vor, ihn zurück
auf null zu setzen, weil der Server einen leeren String gespeichert
hat und Terraform das als Drift gelesen hat. Der Fix war der
UseStateForUnknown-Plan-Modifier; die Lektion war, dass die
Interpretation des Providers von "der Nutzer hat nichts angegeben"
mit der Idee des Servers von "Default-Wert" übereinstimmen muss.
Die Framework-Dokumentation warnt davor in der Einleitung; wir
haben die Warnung beim ersten Mal überlesen. Habe dir die
Peinlichkeit erspart, indem ich es hier aufgeschrieben habe.
Das zweite war das Import-Format. Wir haben zunächst
<workspace_id>/<link_id> mit einem Schrägstrich ausgeliefert,
unter der Annahme, dass sich Pfade natürlicher lesen. Das Framework
hatte kein Problem damit; HCL-Linter und Terminals schon. Ein Pfad
mit zwei Schrägstrichen innerhalb eines einzelnen shell-quotierten
Arguments wird zu etwas, das in Support-Tickets wie ein Tippfehler
aussieht. Wir sind zu einem Doppelpunkt gewechselt, der null
Mehrdeutigkeit hat und Googles Provider-Konventionen entspricht.
Lektion: Import-Strings sind nutzerseitige UI, gestalte sie wie UI.
Das dritte war die Tag-Reihenfolge. Oben besprochen - wir sind
ausgewichen, und wir werden weiter ausweichen, bis jemand fragt.
Die Version, die wir fast ausgeliefert hätten, sortierte Tags
beim Input still, was dazu führte, dass terraform plan keine
Änderungen meldete, wenn der Kunde sie eindeutig umgeordnet hatte.
Das ist eine schlechtere Erfahrung als ein lauter Diff; wir haben
es beim internen Testen gefangen. Wert zu erwähnen, weil die
Versuchung, "hilfreich zu sein", indem man Nutzer-Input
normalisiert, konstant ist, wenn man einen Provider schreibt, und
es ist fast immer die falsche Entscheidung.
Wie man das mit dem Rest von Elido nutzt#
Der Provider ist eine Form. Die anderen Formen existieren weiterhin und gehen nirgendwohin:
- Die REST-API ist die Source of Truth.
Alles, was der Provider tut, ist auch mit
curlmachbar. - Das Go-SDK ist das, was der Provider selbst intern nutzt; du kannst es als Bibliothek einbinden.
- Die TypeScript- und Python-SDKs decken dieselbe Oberfläche für die Sprache ab, in der du gerade bist.
- Der GraphQL-Endpunkt deckt dieselben Reads mit einem einzigen Round-Trip ab, wenn du sie auf deinen Bildschirm zugeschnitten brauchst.
Wähle, was zur Form des Problems passt. Terraform ist richtig, wenn du einen Lebenszyklus zu verwalten hast. Das SDK ist richtig, wenn du ein Script hast. Die REST-API ist richtig, wenn du eine Sache einmal tust. Wir denken, das sollte so offensichtlich sein; wir werden alle vier am Laufen halten.
Wenn du ein Lieblings-Terraform-Muster hast, das uns fehlt - Bulk-
Imports aus CSV via for_each über einen data "external"-Block,
ein for_each, das auf eine Linear-API für Kampagnen-Tracking
zugeschnitten ist, ein Wrapping-Modul für den
Agentur-die-mehrere-Mandanten-verwaltet-Fall - eröffne ein Issue
im GitHub-Repo mit dem
area:terraform-Label. Der Provider existiert, um diese Muster
langweilig zu machen; wir wollen wissen, welche sich noch
überraschend anfühlen.
Wo anfangen#
Wenn du das gelesen hast und es ausprobieren willst: Installiere
den Provider gemäß der Anleitung, richte
ihn auf einen Sandbox-Workspace, schreibe resource "elido_link"
für den Redirect, den du schon immer in Code deklarieren wolltest,
und terraform apply. Wir wetten einen Kaffee, dass das Erste, was
dich überrascht, auf eine gute Art, ist, dass terraform destroy
genau so funktioniert, wie du es erwartest.
Wenn du das gelesen hast und uns mit den Alternativen vergleichen willst - es gibt einen längeren Bericht in unserem Post Bitly Alternatives feature gap, und der Side-by-side unter /compare/vs-bitly zeigt, wo Terraform in der Matrix sitzt. Die Matrix ist seit diesem Post für sie kürzer geworden.
- Marius
Verwandtes im Blog#
Elido testen
URL einfügen, kurzer Link in Sekunden
Kein Konto nötig. Link bleibt 30 Tage aktiv. Konto erstellen, um ihn dauerhaft zu behalten.
Kostenlos, keine Anmeldung erforderlich · 2 pro Tag