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:
regionsizeimageallowed_ssh_cidrshostname- 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:ALLis 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.yamllater 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.yamlreference
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
fail2banis 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, andCSRF_TRUSTED_ORIGINScorrectly. - User data changes: Updating
cloud-init.yamldoes 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 plancarefully. 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.
Internal links
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.