Skip to content

Managing INWX DNS with Pulumi

Published: at 08:00 PM

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

What to watch

That is the whole setup. Clone the repo, uv sync, pulumi up.


Next Post
How I protect my single-node k3s server