Managing personal DNS with Terraform and GitLab CI
I’ve had personal domains for a long time, and like most long-running personal infrastructure they had gathered history. Some records were still valid, some had been copied forward because they looked important, and some were only safe because I knew not to touch them.
That is not a great operating model.
The recent work on my AWS Terraform repository was mostly about making the boring parts more deliberate: public DNS in Route 53, Terraform state in S3, a small amount of dynamic DNS, and GitLab CI doing enough checking to be useful without making production changes too easy.
What is managed
The repository manages a small AWS footprint. Most of it is Route 53 hosted zones and records for personal domains. It also manages the S3-backed Terraform state infrastructure and an account-level S3 public access block.
The important part is ownership. Terraform is allowed to own the records and account controls that are declared in the repository. Old configuration belongs in Git history, not as renamed .tf.old files hanging around in the active tree. If a live DNS record is still needed, it should be documented and managed. If it is not needed, it should be removed.
That sounds obvious, but it is exactly the sort of thing that drifts over time.
Splitting state by responsibility
One of the useful changes was separating the configuration into distinct Terraform roots:
bootstrap/ -> state bucket and account-level S3 controls
main root -> normal Route 53 DNS records
ddns/ -> records updated from a trusted dynamic-DNS host
The bootstrap/ root owns the state bucket. The main DNS root uses that bucket, but does not also manage its own backend resources. That avoids a self-referential mess where the thing using the backend is also trying to own the backend.
The dynamic DNS records are isolated as well. They are updated by a helper script from a trusted host, not by whatever network happens to run a CI job or a laptop plan. The main Terraform root does not discover a public IP address and does not try to manage those records.
That boundary matters. A routine DNS plan should not accidentally decide that my home-hosted records now point at a GitLab runner, hotel Wi-Fi, or wherever I happened to run terraform plan.
Making DDNS less surprising
The dynamic DNS helper is deliberately narrow. It discovers the current public IPv4 address, validates it, initializes the isolated ddns/ Terraform root, creates a saved plan and only applies that exact plan if Terraform reports there are changes.
It rejects malformed addresses and ranges that should never be published as public DNS targets, such as private, reserved and documentation-only IPv4 ranges. It also uses a non-blocking lock so overlapping runs do not fight each other, and cleans up temporary plan and log files on normal exit or signals.
That is more work than a quick shell script that runs terraform apply -auto-approve, but it means the helper has some guard rails. DDNS is supposed to be boring. If it becomes exciting, something has gone wrong.
State safety
Terraform state is sensitive, even for a personal setup. It describes live infrastructure and can contain values that should not be treated casually.
The state bucket now has versioning, server-side encryption, public access blocking and Terraform destruction protection. The backend uses native S3 lock files, which meant the old DynamoDB lock table could be retired once every known Terraform client was new enough.
I also wrote down the recovery process and tested restoring state versions in an isolated drill. That sort of documentation is easy to postpone because the system is working. Unfortunately, the time when you need state recovery documentation is not the time when you want to be inventing it.
CI that proves what it applies
The GitLab CI pipeline now has three stages:
validate -> plan -> apply
Validation can run for merge requests without protected AWS credentials by disabling backend initialization. That means syntax and provider-schema checks still happen for proposed changes, but merge request pipelines do not need production access.
Planning is restricted to protected default-branch workflows. The plan job creates a saved Terraform plan, records the commit SHA beside it and stores a checksum. The artifact is short-lived and maintainer-only.
Apply is manual, default-branch-only and serialized. Before applying, it checks that the saved plan belongs to the current commit and that the checksum still matches. It then shows and applies that exact artifact.
That shape is important. I do not want a CI job to run terraform plan and then later apply something freshly recalculated under different conditions. If I reviewed a plan, that is the plan that should be applied.
Keeping the toolchain pinned
The CI image is pinned to a specific Terraform release and image digest. Terraform provider versions are pinned and committed through lock files. GitLab CI caches downloaded providers using the lock file as the cache key, which avoids repeatedly downloading and unpacking the large AWS provider in small runner containers.
The cache change was prompted by a real failure: provider installation was killed under memory pressure. The fix was not glamorous, but it made the pipeline much more predictable.
DNS cleanup
The DNS work was not just about Terraform mechanics. It also included auditing records, removing stale entries, documenting service-specific destinations and checking mail authentication records.
SPF, DKIM, DMARC and CAA records are the kind of things that can sit untouched for years because nobody wants to break mail delivery or certificate issuance. That is understandable, but it also means they should be written down. If a policy is intentionally relaxed, that should be an explicit choice rather than an accident of history.
I also added destruction guards around hosted zones. Accidentally replacing a hosted zone is not a small change, even if Terraform can technically do it. The registrar delegation, generated name servers and existing records all make it something worth protecting.
What I like about the end result
None of this is especially exotic, and that is the point. It is personal infrastructure treated with the same habits I would want in a larger environment:
- clear ownership boundaries
- reproducible provider installation
- protected state
- documented recovery
- validation before credentials are needed
- reviewed plans before production changes
- manual, serialized applies
- dynamic updates isolated from normal infrastructure plans
The repository is now less dependent on memory and more dependent on rules. I still need to understand the DNS, the Terraform state and the AWS account, but the workflow makes it harder to do the wrong thing casually.
That is usually the best kind of sysadmin improvement. Not clever, just harder to break.