DevOps Advanced

Infrastructure as Code for Django: Terraform, Ansible, and Reproducible Servers

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.

DjangoZen Team Sep 05, 2026 20 min read 17 views

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.

Two tools, two jobs

The confusion that wastes the most time is trying to do everything with one tool. The split is clean:

  • Terraform provisions. It talks to your provider's API and creates things that did not exist: servers, DNS records, firewalls, storage buckets, databases. It keeps state so it knows what it made.
  • Ansible configures. It connects over SSH to a machine that already exists and makes its contents correct: packages, users, files, services.

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: describe the machine, not the clicks

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:

  • Remote state with locking. Local state on a laptop means two people can apply at once and corrupt it. This is the first thing to set up, not the last.
  • Pin versions. Both Terraform and the provider plugin. An unpinned provider upgrade will one day propose to replace your database.
  • Read every plan. The word to fear is destroy, or replace on anything holding data. terraform plan is not a formality; it is the whole safety mechanism.

Environments as workspaces, not copy-paste

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.

Keeping secrets out of state

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:

  • Generate secrets outside Terraform and inject them at configuration time with Ansible Vault or a secret manager. Terraform never sees them.
  • Let the provider generate them and read them at apply time from a secret store, never from a variable file.

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: make the contents correct, repeatedly

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.

Vault for the values that must not be in git

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.

Wiring it into a pipeline

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.

Drift is the thing you are actually preventing

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.

Proving it works before you need it

Infrastructure code that has never been run from scratch is a hypothesis. Test it:

  • Rebuild staging monthly from nothing. Destroy, apply, restore last night's database backup, run smoke tests. This single habit finds the missing package, the manual DNS record, and the undocumented cron job.
  • Time it. "We can rebuild in 40 minutes" is a number you can tell a customer. "We think we could" is not.
  • Molecule for roles if your Ansible grows past a handful of roles — it runs each role in a container and asserts the outcome.

The Django-specific parts people forget

  • Migrations are not infrastructure. Run them in your deploy step, once, not in a playbook that runs on every host. Two hosts migrating simultaneously is a bad afternoon.
  • Static files and media are different problems. Static is built and shipped with the image; media is state and belongs on object storage, or your "rebuild the server" plan quietly destroys user uploads.
  • The database is the exception to disposability. Everything else can be rebuilt; the database is restored. Put prevent_destroy on it and mean it.
  • Keep a bootstrap path. Note somewhere how to reach a server when your normal tooling is broken — the console, the recovery key, who has it. IaC is not a substitute for being able to get in.

Where to start if everything is manual today

Do not rewrite your production estate this month. Do this instead:

  1. Import what exists into Terraform state so it stops being invisible.
  2. Write the Ansible role for one thing you already understand — nginx, or the app user.
  3. Build staging entirely from code, from zero. Learn where reality differs from your assumptions.
  4. Only then move production, one resource at a time, reading every plan.

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.