Provision a Django Server with cloud-init
Use cloud-init user data to provision a Django-ready Ubuntu server on first boot: users and SSH keys, packages, firewall, swap, directories, systemd units, and verification.
Problem statement
The first hour of every new Django server looks the same: create a deploy user, add SSH keys, disable password logins, install Python and Nginx, enable a firewall, add swap, create directories, and write systemd units. Doing it by hand produces servers that differ in small ways, and those differences surface later as "works on web1, fails on web2".
Almost every cloud provider (AWS, Hetzner, DigitalOcean, Linode, Vultr, OVHcloud, and others) supports cloud-init: a YAML document passed as user data when the server is created, applied automatically on first boot. This guide writes a cloud-init configuration that turns a fresh Ubuntu instance into a Django-ready host before you ever log in.
- Write a
#cloud-configfile withusers,ssh_pwauth: false,package_update, andpackages. - Use
write_filesfor the SSH hardening drop-in, the environment file skeleton, and the Gunicorn systemd unit. - Use
runcmdfor steps that need commands: UFW rules, swap file, directories, enabling services. - Validate the file with
cloud-init schema --config-file. - Pass it as user data when creating the server (
--user-data-file,--user-data, or the console field). - Check
cloud-init status --longand/var/log/cloud-init-output.logafter boot.
Step-by-step solution
1. Write the cloud-config
#cloud-config
# django-server.yaml
users:
- name: deploy
groups: [sudo, www-data]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAAC3... ops@example.com
ssh_pwauth: false
disable_root: true
timezone: Etc/UTC
package_update: true
package_upgrade: true
packages:
- python3-venv
- python3-dev
- build-essential
- libpq-dev
- nginx
- git
- ufw
- unattended-upgrades
write_files:
- path: /etc/ssh/sshd_config.d/10-hardening.conf
permissions: "0644"
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
- path: /etc/myapp.env
owner: root:root
permissions: "0640"
content: |
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
# DJANGO_SECRET_KEY and DATABASE_URL are added by the deploy tooling
- path: /etc/systemd/system/gunicorn.service
permissions: "0644"
content: |
[Unit]
Description=Gunicorn for myapp
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp.env
RuntimeDirectory=gunicorn
ExecStart=/srv/myapp/current/.venv/bin/gunicorn config.wsgi:application --bind unix:/run/gunicorn/gunicorn.sock --workers 3 --access-logfile -
Restart=on-failure
[Install]
WantedBy=multi-user.target
- path: /etc/systemd/journald.conf.d/size.conf
permissions: "0644"
content: |
[Journal]
SystemMaxUse=500M
runcmd:
- [chgrp, www-data, /etc/myapp.env]
- [mkdir, -p, /srv/myapp/releases, /srv/myapp/shared/media]
- [chown, -R, "deploy:www-data", /srv/myapp]
- [fallocate, -l, 2G, /swapfile]
- [chmod, "600", /swapfile]
- [mkswap, /swapfile]
- [swapon, /swapfile]
- [sh, -c, "echo '/swapfile none swap sw 0 0' >> /etc/fstab"]
- [ufw, default, deny, incoming]
- [ufw, allow, OpenSSH]
- [ufw, allow, "Nginx Full"]
- [ufw, --force, enable]
- [systemctl, restart, ssh]
- [systemctl, restart, systemd-journald]
- [systemctl, daemon-reload]
- [systemctl, enable, gunicorn]
- [rm, -f, /etc/nginx/sites-enabled/default]
- [systemctl, reload, nginx]
Notes:
lock_passwd: trueleaves the user without a usable password; access is by SSH key only.write_filesruns beforepackagesare installed by default. Files owned by users or groups that do not exist yet (such aswww-databefore Nginx is installed) should get their ownership adjusted inruncmd, as done for the environment file here.- The Gunicorn unit is enabled but not started:
/srv/myapp/currentdoes not exist until the first release is deployed. - Never put real secrets in user data. Most providers expose user data to anything on the instance through the metadata service, and it is often visible in the provider console. Add secrets later with your deploy tool or read them from a secrets manager.
- On Ubuntu, the SSH service is named
ssh; on some other distributions it issshd.
2. Validate before using it
cloud-init schema --config-file django-server.yaml
A YAML mistake usually means cloud-init skips the whole document and you get a bare server. Validate locally (cloud-init is installable on most Linux systems), and keep the file in version control.
3. Create a server with it
Examples for a few providers:
# Hetzner Cloud
hcloud server create --name web1 --type cx22 --image ubuntu-24.04 --user-data-from-file django-server.yaml
# DigitalOcean
doctl compute droplet create web1 --region fra1 --size s-1vcpu-2gb --image ubuntu-24-04-x64 --user-data-file django-server.yaml
# AWS EC2
aws ec2 run-instances --image-id ami-xxxxxxxx --instance-type t3.small --user-data file://django-server.yaml
Check your provider's current image and size names; they change over time.
4. Confirm it worked
ssh deploy@<server-ip>
cloud-init status --long
sudo tail -n 50 /var/log/cloud-init-output.log
sudo ufw status verbose
swapon --show
systemctl is-enabled gunicorn
cloud-init status --wait blocks until cloud-init finishes, which is useful in scripts that provision and then deploy.
Verification check:
status: donewith no errors.ssh root@<server-ip>and password logins are refused.- UFW allows only SSH, HTTP, and HTTPS.
/srv/myappexists and is owned bydeploy.
5. Hand over to the deploy tool
cloud-init prepares the host; it is not a deploy mechanism. After first boot, your deploy script, Fabric, Ansible, or CI job adds the secret values to /etc/myapp.env, creates the first release under /srv/myapp/releases/, points current at it, runs migrations, and starts Gunicorn. Nginx site configuration and TLS certificates usually belong to that step too, because they depend on the domain being pointed at the server.
Explanation
cloud-init runs early in the boot process and applies modules in stages: it creates users and writes files, installs packages, and finally runs runcmd commands once, on the first boot of each instance. Because the configuration is declarative and attached at creation time, every server built from the same file starts identical. Keeping application releases out of it means the file changes rarely and can be reviewed like any other infrastructure code.
Edge cases / notes
Re-running cloud-init
cloud-init runs per instance, not per boot. To test changes on the same machine, run sudo cloud-init clean --logs and reboot, but prefer creating a fresh server: that is the scenario the file must work in.
Terraform integration
Terraform providers accept user data for server resources (user_data = file("django-server.yaml") or templatefile() for variables). Changing user data typically forces the server to be replaced, which is the intended workflow for immutable hosts.
Distributions
Package names, the SSH service name, and firewall tools differ between distributions. Keep one cloud-config per distribution rather than branching inside it.
When to automate this
cloud-init is the first automation step for new servers. Combine it with Terraform for creating the server and DNS records, and with a deploy tool for releases. Together they make replacing a broken server a routine, tested operation.
Next steps
- Create servers and DNS declaratively with Provision a Django Server Baseline with Terraform.
- Review what else to lock down with the Linux Server Hardening Checklist for Django.
- Complete the application setup with Deploy Django with Gunicorn and Nginx on Ubuntu.
- If Gunicorn does not start after the first release, see Fix a Gunicorn systemd Service That Fails to Start.
FAQ
Is it safe to put secrets in cloud-init user data?
No. User data is readable through the instance metadata service and often in the provider's console. Inject secrets afterwards or fetch them from a secrets manager at runtime.
Why didn't my cloud-init configuration run?
The first line must be exactly #cloud-config, and a YAML error can cause the document to be ignored. Validate with cloud-init schema and read /var/log/cloud-init.log.
Does cloud-init run on every boot?
Most modules, including runcmd, run once per instance. Some run every boot. Use cloud-init clean only for testing.
Should cloud-init deploy the Django code too?
It can, but separating host provisioning from releases is easier to maintain. Releases happen many times; provisioning happens once per server.