Stop configuring servers by hand. Provision with Terraform, configure with Ansible, keep secrets out of state, and rebuild any environment from an empty account in under an hour.
Every hand-configured server is a document nobody wrote. It works, and no one is quite sure why. Infrastructure as code replaces that document with something a machine can execute — and the real payoff is not automation, it is that rebuilding becomes routine instead of terrifying.
The confusion that wastes the most time is trying to do everything with one tool. The split is clean:
Terraform makes the box. Ansible fills it. Use Terraform to install packages and you get a fragile mess of remote-exec blocks; use Ansible to create servers and you lose the dependency graph that makes planning possible.
terraform {
required_version = ">= 1.9"
backend "s3" {
bucket = "tfstate-example"
key = "django-prod/terraform.tfstate"
region = "eu-central-1"
use_lockfile = true
}
}
variable "environment" { type = string }
variable "server_type" { type = string, default = "cx32" }
resource "provider_server" "app" {
name = "django-${var.environment}"
server_type = var.server_type
image = "ubuntu-24.04"
ssh_keys = [provider_ssh_key.deploy.id]
labels = { role = "app", env = var.environment }
}
resource "provider_firewall" "app" {
name = "django-${var.environment}"
rule { direction = "in", protocol = "tcp", port = "80", source_ips = ["0.0.0.0/0", "::/0"] }
rule { direction = "in", protocol = "tcp", port = "443", source_ips = ["0.0.0.0/0", "::/0"] }
rule { direction = "in", protocol = "tcp", port = "22", source_ips = var.admin_cidrs }
}
Three habits that separate infrastructure code that lasts from a script that rots:
destroy, or replace on anything holding data. terraform plan is not a formality; it is the whole safety mechanism.module "staging" {
source = "./modules/django-stack"
environment = "staging"
server_type = "cx22"
domain = "staging.example.com"
}
module "production" {
source = "./modules/django-stack"
environment = "production"
server_type = "cx42"
domain = "example.com"
}
One module, two calls. The moment you copy the directory and edit it, staging stops resembling production, and staging that does not resemble production tests nothing.
This trips up almost everyone once: Terraform state contains every value it manages, in plain text. A database password passed as a variable is in the state file forever, including in its history.
Two workable approaches:
And regardless: encrypt the state bucket, restrict who can read it, and never commit terraform.tfstate — put it in .gitignore on day one, because removing it later means rotating everything it contained.
Ansible's value is idempotence: running the playbook on a fresh server and on a five-month-old server both end with the same result.
- name: Configure Django application server
hosts: app
become: true
roles:
- common # users, ssh hardening, unattended-upgrades, timezone
- docker
- django_app
- nginx
- monitoring
# roles/django_app/tasks/main.yml
- name: Application directory
ansible.builtin.file:
path: /opt/{{ app_name }}
state: directory
owner: "{{ app_user }}"
mode: "0750"
- name: Environment file
ansible.builtin.template:
src: env.j2
dest: /opt/{{ app_name }}/.env
owner: "{{ app_user }}"
mode: "0600"
no_log: true
notify: restart app
- name: Compose stack
community.docker.docker_compose_v2:
project_src: /opt/{{ app_name }}
state: present
pull: always
no_log: true on anything touching secrets is not optional — without it your CI log prints the rendered environment file, and CI logs are widely readable.
ansible-vault encrypt_string --name DJANGO_SECRET_KEY 'the-actual-value'
The encrypted blob goes in group_vars/production/vault.yml and is committed safely. The vault password lives in your password manager and in CI as a secret. You now have one secret to protect instead of thirty.
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init && terraform plan -out=tfplan
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: tfplan }
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production # requires a human approval
steps:
- run: terraform apply tfplan # the exact plan that was reviewed
Two properties matter. Apply the saved plan, not a fresh one — otherwise you approve one thing and execute another. And gate production behind a human. Infrastructure automation should remove typing, not remove judgment.
Someone SSHes in at 2am, edits nginx, and fixes an outage. Nobody writes it down. Three months later a rebuild produces a server that is subtly different, and the bug comes back.
Catch it: run terraform plan -detailed-exitcode and ansible-playbook --check --diff on a schedule. Exit code 2 means reality no longer matches the code. Alert on it, then either bring the change into code or revert it — but never leave it undecided.
The cultural rule that makes this work: emergency SSH fixes are allowed, and must be followed by a pull request the same week. Forbidding them entirely just means they stop being mentioned.
Infrastructure code that has never been run from scratch is a hypothesis. Test it:
prevent_destroy on it and mean it.Do not rewrite your production estate this month. Do this instead:
The end state is not "we automated the servers". It is that a failed machine is an inconvenience rather than an incident, because the thing that made it is checked into git.