I have four domains at INWX: the one behind this blog’s infrastructure, two product domains and the domain of my app studio, tawadi.de. For years I managed their records in the INWX web UI. That works until you need to know what a record is for, or you move a site and forget the old wildcard.
Now the records are code. One Pulumi program, Python, about 450 lines, and
pulumi up.
The provider
INWX has no Pulumi provider, but it has a Terraform provider (inwx/inwx). Pulumi can bridge any Terraform provider at runtime:
pulumi package add terraform-provider inwx/inwx 1.7.1
That generates a local SDK under sdks/inwx/ and adds it to Pulumi.yaml:
packages:
inwx:
source: terraform-provider
version: 1.1.1
parameters:
- inwx/inwx
- 1.7.1
Careful, the two versions are easy to mix up: version is Pulumi’s
terraform-provider bridge, the INWX provider version is the second entry under
parameters. Leave that out and the next SDK generation takes whatever INWX
version is current.
The SDK is a normal Python package. pyproject.toml points at the local path,
uv sync installs it, and the program imports it like any other library.
Since August 2026 Pulumi also runs
HCL as a language of its own.
That does not change this route: for a Python, TypeScript or Go program,
pulumi package add terraform-provider is still how you get a Terraform
provider. A Pulumi HCL program skips that step: providers resolve against the
OpenTofu registry and are bridged automatically, pinned with a
required_providers block if you need a specific version. For records that
are mostly data, that is a fair alternative. My program stays in Python.
The program
Credentials come from the stack config as secrets:
import pulumi
import pulumi_inwx as inwx
config = pulumi.Config()
provider = inwx.Provider(
"inwx",
username=config.require_secret("inwxUsername"),
password=config.require_secret("inwxPassword"),
)
Records are resources. An A/AAAA pair for a host, a CNAME to a CDN, an ALIAS at the apex:
TAWADI_DOMAIN = "tawadi.de"
TAWADI_CDN = "tawadi.b-cdn.net"
inwx.NameserverRecord(
"tawadi-apex-alias",
domain=TAWADI_DOMAIN,
name=TAWADI_DOMAIN,
type="ALIAS",
content=TAWADI_CDN,
ttl=300,
opts=pulumi.ResourceOptions(provider=provider),
)
inwx.NameserverRecord(
"tawadi-www-cname",
domain=TAWADI_DOMAIN,
name=f"www.{TAWADI_DOMAIN}",
type="CNAME",
content=TAWADI_CDN,
ttl=300,
opts=pulumi.ResourceOptions(provider=provider),
)
The ALIAS type is an INWX feature: a CNAME-like record at the apex that INWX resolves to A/AAAA on its side, so the MX and SPF records next to it keep working.
Repeated patterns become functions. One domain has a helper that creates the A and AAAA record for a hostname, and its dev/prod hosts are one loop.
Two-factor auth and the API
If your INWX account has 2FA on, the API wants a TAN too. The provider knows
that: it has a tan argument (or INWX_TAN in the environment), and it works.
Once. A mobile TAN is good for one login, so every pulumi up would need a
fresh one typed in. Fine for a laptop, useless for CI.
I asked INWX support. They created a second login for me, API only, without 2FA, tied to the same account. That is what the Pulumi stack uses. The web login keeps its 2FA. The API credentials sit encrypted in the stack config and never in the repo as plain text.
Since version 1.7 the provider has a second way: shared_secret (or
INWX_SHARED_SECRET) takes the Base32 TOTP secret and computes a fresh TAN on
every login, so a 2FA account works in CI too. My stack still uses the API
login.
What I get out of it
- Every record has a reason. The resource name says what it is for, and a
comment next to it says why.
git logsays when it changed and in which commit. pulumi previewbefore every change. Adding a TXT record for Google Search Console was: one resource in the file,pulumi previewshows+ 1 to create,pulumi up. No hunting for the right form in the UI, no typo in the hostname.- Drift shows up. If I change something in the UI in a hurry, the next preview shows the difference. Records I do not declare stay untouched; the provider only manages what is in the program.
- Four domains, one place. The site repo for tawadi.de says in its README where DNS lives. Six months from now that saves me the search.
- The same login works elsewhere. My cert-manager webhook for INWX uses the same API user for DNS-01 challenges. One credential to rotate.
What to watch
- The bridged provider follows the Terraform one, fixes and bugs included. Pin
the INWX version under
parametersinPulumi.yamland bump it on purpose: rerunpulumi package addwith the new version and read the preview. - INWX API changes reach every client, not only Pulumi. When INWX started returning record IDs as strings instead of ints, my cert-manager webhook for INWX (an older upstream release) stopped renewing certificates until I moved to a fork that handles string IDs.
- Mail records I leave out of the program by choice. SPF, DKIM and DMARC for mailbox.org were set up once and should not go away because someone deletes a stack.
- Import before you declare. A record that already exists in the UI and gets
declared in code without
pulumi importends up twice.
That is the whole setup. Clone the repo, uv sync, pulumi up.