๐Ÿ—๏ธ Terraform + Ansible: Deploy Django Without Manual Server Steps

October 14, 2026 ยท Ahmad M. Waddah ยท 48 reads

๐Ÿ—๏ธ Manual deployment relies on memory. Memory fails. Code does not.

๐Ÿ” The Problem with Manual Setup

Every manual deployment introduces variation: a missed environment variable, a different Python version, a forgotten firewall rule. These differences cause bugs that are impossible to reproduce locally. The server works differently from development, and no one remembers exactly why.

This is the "works on my machine" problem pushed one layer down โ€” not the app code, but the infrastructure underneath it. A manually built server is effectively untested: it's a one-off that happened to work, not a configuration anyone has verified twice.

โœ… Terraform: Define Infrastructure

Terraform creates the server, network interfaces, and firewall rules through declarative configuration files. Changing the server requires editing a file and running terraform apply. The state is tracked. The results are predictable.

resource "hcloud_server" "django_app" {
  name        = "django-prod"
  server_type = "cx22"
  image       = "ubuntu-24.04"
  location    = "nbg1"

  ssh_keys = [hcloud_ssh_key.deploy.id]
}

resource "hcloud_firewall" "web" {
  name = "django-firewall"

  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = ["0.0.0.0/0"]
  }

  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "443"
    source_ips = ["0.0.0.0/0"]
  }
}

terraform plan shows exactly what will change before anything is applied โ€” additions, modifications, and deletions, all listed against the current tracked state. Nothing happens silently.

โœ… Ansible: Configure the Environment

Ansible connects to the created server and executes tasks: install Python packages, clone the Django repository, configure environment variables, set up the database connection, run migrations, and restart the service. Each step is defined in a YAML file. The entire process runs in sequence without human intervention.

- name: Configure Django server
  hosts: django_prod
  become: true
  tasks:
    - name: Install system packages
      apt:
        name: [python3-pip, python3-venv, postgresql, nginx]
        state: present

    - name: Clone application repository
      git:
        repo: "{{ app_repo }}"
        dest: /opt/django_app
        version: main

    - name: Install Python dependencies
      pip:
        requirements: /opt/django_app/requirements.txt
        virtualenv: /opt/django_app/venv

    - name: Run database migrations
      command: /opt/django_app/venv/bin/python manage.py migrate
      args:
        chdir: /opt/django_app

    - name: Restart Gunicorn
      systemd:
        name: gunicorn
        state: restarted

๐Ÿ”— How the Two Tools Hand Off

Terraform and Ansible solve different problems and deliberately don't overlap:

Terraform Ansible
Question it answers What servers, networks, and firewalls exist? What's installed and configured on them?
Unit of work Resources (server, IP, firewall rule) Tasks (install package, copy file, restart service)
State tracking Maintains a state file of real-world resources Stateless โ€” re-runs tasks and checks current condition
Typical trigger Infrequent โ€” new server, resize, new firewall rule Frequent โ€” every deploy, every config change

The handoff point is the server's IP address. Terraform outputs it; Ansible's dynamic inventory (or a simple output-to-inventory step) picks it up:

output "server_ip" {
  value = hcloud_server.django_app.ipv4_address
}
terraform output -raw server_ip >> ansible/inventories/production/hosts.ini
ansible-playbook -i ansible/inventories/production/hosts.ini site.yml

๐Ÿ“Š Reproducibility

If a server fails, rebuild it with the same Terraform and Ansible files. The new server is identical to the old one. No manual steps mean no forgotten configurations. The infrastructure and its setup become version-controlled artifacts.

Concretely, that makes disaster recovery a known procedure instead of a scramble:

terraform apply          # recreate the server and firewall
ansible-playbook site.yml  # reinstall and configure everything on it

Two commands, not a mental checklist assembled under pressure.

โš ๏ธ Where Teams Get This Wrong

  • Treating Terraform state as disposable. The state file is the source of truth for what Terraform thinks exists. Losing it (or editing resources outside Terraform) causes drift that's painful to reconcile. Store it remotely (S3, Terraform Cloud) with locking, not as a local file.
  • Putting secrets in .tf files. Database passwords and API keys belong in a secrets manager or an encrypted vault (Ansible Vault, SOPS), referenced by variable โ€” never committed in plaintext alongside infrastructure code.
  • Running Ansible against a server Terraform didn't create. Once a server is modified by hand even once, it's no longer reproducible, even if the Ansible playbook still runs cleanly against it.
  • No idempotency check. A well-written Ansible playbook can run twice with no side effects. If running it a second time changes anything unexpected, a task is doing something it shouldn't (e.g., appending to a file instead of templating it).

๐ŸŽฏ Key Takeaway

Terraform defines what exists. Ansible defines how it is configured. Together they eliminate manual server setup, reduce errors, and make Django deployments reproducible. This is not advanced practice โ€” it is the baseline for any serious deployment.