๐๏ธ 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
.tffiles. 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.