Operations
#django
#terraform
#cloud

Provision a Django Server Baseline with Terraform

Manual server creation is one of the fastest ways to introduce deployment drift into a Django environment.

Problem statement

Manual server creation is one of the fastest ways to introduce deployment drift into a Django environment. One VPS has SSH password login disabled, another does not. One host has a firewall, another exposes too many ports. A third server was bootstrapped by hand months ago and nobody remembers which packages or hardening steps were applied.

For Django production work, that inconsistency becomes an operations problem. App deploys fail on one host and succeed on another. SSH access is fragile. Security settings vary. Recovery is slower because rebuilding the server is not repeatable.

This page shows how to provision a repeatable production server baseline for Django with Terraform. The scope is intentionally limited to infrastructure baseline: server creation, firewall, SSH access, and first-boot bootstrap with cloud-init. It does not cover deploying Django application code, running migrations, issuing TLS certificates, or configuring the final app reverse proxy.

Quick answer

Use Terraform to provision a Linux server with a known image, size, region, SSH keys, firewall policy, and a first-boot bootstrap script via cloud-init. Create a non-root admin user, disable SSH password and keyboard-interactive authentication, enable security updates, and install only baseline packages.

Keep provider credentials out of the repository, avoid putting app secrets into Terraform variables, and use remote state with access controls if this is a shared or production project. Before deploying Django, verify SSH access, provider firewall exposure, cloud-init completion, and the expected OS and package baseline.

Use version-controlled Terraform changes and review plan output carefully. Rollback may mean reverting Terraform configuration and applying again, but bootstrap and immutable instance changes often require server replacement rather than in-place rollback. Keep durable data outside the app server.

Step-by-step solution

1. Define the server baseline

For a Django host, the baseline should usually include:

  • Ubuntu LTS or another stable Linux image
  • SSH key access only
  • A non-root administrative or deploy user
  • Basic provider firewall rules
  • Security update policy
  • Time sync, logging, and basic intrusion protection such as fail2ban
  • Optional runtime bootstrap such as Docker or Python build dependencies
  • Consistent naming and tags

This page does not provision:

  • Django code release
  • database migrations
  • TLS certificates
  • Nginx site config for the app
  • full monitoring or backup stacks

2. Use a maintainable Terraform layout

A simple layout is enough for a single server baseline:

.
├── providers.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── terraform.tfvars
└── cloud-init.yaml

Keep environment-specific values separate in terraform.tfvars, such as:

  • region
  • size
  • image
  • allowed_ssh_cidrs
  • hostname
  • tags

Do not store provider tokens in .tfvars. Export them as environment variables instead.

Example:

export DIGITALOCEAN_TOKEN='your-token-here'

If you use Git, ensure .gitignore includes sensitive local files:

.terraform/
*.tfstate
*.tfstate.*
*.tfplan
terraform.tfvars

3. Configure the provider and remote state

This example uses DigitalOcean to keep the provider syntax consistent. The same pattern applies to EC2, Hetzner, or other VPS providers.

providers.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
  }

  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "django-server-baseline/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

provider "digitalocean" {}

Remote state matters because local state is fragile for team or production use. Use a backend with access controls, encryption, and locking. On AWS-backed remote state, an S3 bucket plus DynamoDB locking is a common setup.

4. Define variables

variables.tf:

variable "project_name" {
  type = string
}

variable "region" {
  type = string
}

variable "size" {
  type = string
}

variable "image" {
  type = string
  default = "ubuntu-22-04-x64"
}

variable "ssh_key_fingerprints" {
  type = list(string)
}

variable "allowed_ssh_cidrs" {
  type = list(string)
}

variable "hostname" {
  type = string
}

terraform.tfvars:

project_name         = "myapp-prod"
region               = "nyc3"
size                 = "s-1vcpu-1gb"
hostname             = "myapp-web-01"
ssh_key_fingerprints = ["ab:cd:ef:12:34:56:78:90:12:34:56:78:90:ab:cd:ef"]
allowed_ssh_cidrs    = ["203.0.113.10/32"]

Restrict SSH CIDRs to trusted office or home IPs where possible. Avoid 0.0.0.0/0 for SSH in production.

5. Provision the server and firewall

main.tf:

resource "digitalocean_droplet" "django_server" {
  name       = var.hostname
  region     = var.region
  size       = var.size
  image      = var.image
  ssh_keys   = var.ssh_key_fingerprints
  user_data  = file("${path.module}/cloud-init.yaml")
  monitoring = true
  tags       = [var.project_name, "django", "baseline"]
}

resource "digitalocean_firewall" "django_server_fw" {
  name = "${var.project_name}-fw"

  droplet_ids = [digitalocean_droplet.django_server.id]

  inbound_rule {
    protocol         = "tcp"
    port_range       = "22"
    source_addresses = var.allowed_ssh_cidrs
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "80"
    source_addresses = ["0.0.0.0/0", "::/0"]
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "443"
    source_addresses = ["0.0.0.0/0", "::/0"]
  }

  outbound_rule {
    protocol              = "tcp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }

  outbound_rule {
    protocol              = "udp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
}

This example uses the provider firewall as the baseline network control. The outbound policy is intentionally permissive, which is common for general-purpose app servers.

If you do not plan to install a reverse proxy immediately, omit ports 80 and 443 until needed.

If stable DNS cutovers matter, add a reserved IP rather than relying on an ephemeral server IP.

6. Bootstrap safely with cloud-init

Terraform is provisioning the server, while cloud-init handles first-boot configuration inside the instance. Keep that boundary clear: Terraform manages infrastructure state; cloud-init handles initial OS bootstrap.

cloud-init.yaml:

#cloud-config
package_update: true
package_upgrade: true

users:
  - default
  - name: deploy
    groups: sudo
    shell: /bin/bash
    sudo: ["ALL=(ALL) NOPASSWD:ALL"]
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...replace-with-your-public-key

write_files:
  - path: /etc/ssh/sshd_config.d/99-hardening.conf
    permissions: '0644'
    content: |
      PasswordAuthentication no
      KbdInteractiveAuthentication no
      PubkeyAuthentication yes
      PermitRootLogin no

packages:
  - fail2ban
  - unattended-upgrades
  - apt-listchanges
  - ca-certificates
  - curl
  - git
  - vim
  - htop

runcmd:
  - timedatectl set-timezone UTC
  - sshd -t
  - systemctl restart ssh
  - systemctl enable fail2ban
  - systemctl start fail2ban
  - dpkg-reconfigure -f noninteractive unattended-upgrades

Notes:

  • NOPASSWD:ALL is convenient for automation and bootstrap access, but it is a broad privilege grant. Tighten sudo policy later if your team does not need that level of access.
  • This example relies on the provider firewall, so it does not install or enable UFW. If you want both provider-level and host-level firewalling, configure host firewall rules explicitly instead of just installing the package.
  • If you install Docker, Nginx, or Python build packages here, keep that baseline-oriented.
  • Avoid embedding Django settings, database passwords, or application secrets in cloud-init.
  • Cloud-init is mainly a first-boot mechanism. Editing cloud-init.yaml later does not mean those changes will automatically apply to existing servers.
  • If you need repeatable in-place configuration changes across existing hosts, use cloud-init for initial access and hand off to Ansible or a deploy pipeline.

7. Add useful outputs

outputs.tf:

output "server_ip" {
  value = digitalocean_droplet.django_server.ipv4_address
}

output "server_name" {
  value = digitalocean_droplet.django_server.name
}

output "ssh_command" {
  value = "ssh deploy@${digitalocean_droplet.django_server.ipv4_address}"
}

8. Validate, plan, and apply

Run:

terraform init
terraform fmt
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

Before applying, review the plan for:

  • unexpected resource replacement
  • overly broad firewall exposure
  • the correct image, size, and region
  • the expected cloud-init.yaml reference

9. Verify the baseline before deploying Django

Get outputs:

terraform output

Connect by SSH:

ssh deploy@server-ip

Run baseline checks:

whoami
lsb_release -a
cloud-init status --wait
systemctl status ssh
systemctl status fail2ban
sudo sshd -t
tail -n 100 /var/log/cloud-init-output.log
sudo ss -tulpn
sudo ip6tables -L -n 2>/dev/null || true

What to confirm:

  • login works as deploy
  • the correct Ubuntu version is installed
  • cloud-init completed and did not leave obvious errors in logs
  • SSH config validates cleanly
  • fail2ban is running
  • only expected ports are listening
  • IPv6 exposure matches your expected network policy

Also verify firewall behavior at the right layer:

  • Provider firewall: check the Terraform configuration and provider console or CLI
  • Host firewall: only verify this if you explicitly configured one

Explanation

This setup works because it separates baseline provisioning from application deployment.

Terraform is good at managing infrastructure resources and their desired state:

  • server creation
  • firewall attachment
  • reserved IPs
  • tags and metadata
  • bootstrap file delivery

Cloud-init is good for first-boot initialization:

  • creating users
  • setting SSH policy
  • installing baseline packages
  • enabling services

That separation keeps Terraform focused on infrastructure and avoids turning it into a full application deploy tool. In a Django workflow, that usually leads to fewer surprises. Your app release process can then be handled by CI/CD, Ansible, shell scripts, or a container workflow.

Be careful with change types. Updating tags or firewall rules is usually low risk. Changing image IDs, some bootstrap settings, or instance-level properties can force recreation depending on provider behavior. Bootstrap changes are especially important here: changing user_data or cloud-init.yaml does not give you a safe in-place rollback model, because cloud-init usually does not re-run fully on an existing instance.

That is why durable state should live outside the server where possible:

  • PostgreSQL on a managed service or separate host
  • Redis on a dedicated service or node
  • user-uploaded media on object storage

If the app server is disposable, rebuilding from Terraform is safer and faster.

When this manual setup becomes repetitive

Once you are provisioning more than one Django environment, this baseline should become a reusable module and a standard cloud-init template. The first pieces worth automating are provider inputs, bootstrap rendering, and post-provision validation checks. That keeps environments consistent without mixing app release logic into the infrastructure baseline.

Edge cases / notes

  • Static and media files: Do not assume they belong in Terraform. Static collection and media storage are app deployment concerns, often handled by object storage or the app release pipeline.
  • Migrations: Terraform should not run Django migrations. That belongs in your deploy workflow after the server baseline is verified.
  • Proxy and TLS headers: If this host will later run Nginx or Caddy in front of Gunicorn or Uvicorn, make sure your future Django settings handle SECURE_PROXY_SSL_HEADER, ALLOWED_HOSTS, and CSRF_TRUSTED_ORIGINS correctly.
  • User data changes: Updating cloud-init.yaml does not necessarily re-run setup on an existing server. Treat bootstrap edits as affecting new hosts unless you intentionally manage in-place changes another way.
  • Rollback and recovery: If a Terraform change breaks access, first revert the change in version control and inspect terraform plan carefully. If SSH is lost, use provider console or rescue access. For broken bootstrap changes, rebuilding a fresh host from the last known-good baseline is often safer than trying to repair a drifted server manually.

For the infrastructure boundary, start with What Terraform Should Manage in a Django Deployment.

After the baseline exists, the next app-layer step is usually Deploy Django with Gunicorn and Nginx on Ubuntu.

If your stack will use separate services, continue with Set Up PostgreSQL and Redis for Django Production.

If provisioning fails or SSH access is broken, use How to Troubleshoot Django Server Provisioning and SSH Access Issues.

FAQ

Should Terraform install Django and deploy application code too?

Usually no. Terraform should provision infrastructure baseline, not act as your app release system. Keep Django code deploys, migrations, static collection, and restarts in CI/CD, Ansible, or release scripts.

Is cloud-init enough for server bootstrap, or should I use Ansible as well?

Cloud-init is enough for first-boot baseline setup on many small deployments. If you need repeatable in-place configuration changes across existing hosts, Ansible is often a better fit.

How do I keep secrets out of Terraform state when provisioning a Django server?

Do not put app secrets, database passwords, or .env contents into Terraform variables unless you fully understand the state exposure risk. Use provider credentials via environment variables, and inject app secrets later through a secret manager, CI/CD, or host-level secret distribution.

What Terraform changes are most likely to recreate the server?

Image changes, some instance configuration changes, and provider-specific immutable fields are common triggers. Always inspect terraform plan for replace actions before applying.

Should PostgreSQL run on the same Terraform-provisioned server as Django?

For small projects it can, but it increases coupling and makes rebuilds harder. For production reliability, keeping PostgreSQL and other durable services outside the app server is usually the safer baseline.

2026 · django-deployment.com - Django Deployment knowledge base