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.

cloud-init first bootUser datacloud-config YAMLUsers and SSHkeys, no passwordsPackagesPython, Nginx, UFWFiles and unitsenv, systemdcloud-init runs once per instance on first boot; application releases stay in your deploy tool.
  1. Write a #cloud-config file with users, ssh_pwauth: false, package_update, and packages.
  2. Use write_files for the SSH hardening drop-in, the environment file skeleton, and the Gunicorn systemd unit.
  3. Use runcmd for steps that need commands: UFW rules, swap file, directories, enabling services.
  4. Validate the file with cloud-init schema --config-file.
  5. Pass it as user data when creating the server (--user-data-file, --user-data, or the console field).
  6. Check cloud-init status --long and /var/log/cloud-init-output.log after 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]
what cloud-init does, in orderusersdeploy user and SSH keyspackage_update, packagesPython, Nginx, PostgreSQL clientwrite_filessshd drop-in, unit filesruncmdfirewall, services, final stepsIt runs once, at first bootdjango-deployment.com
Changing the file later does not change servers already created.

Notes:

  • lock_passwd: true leaves the user without a usable password; access is by SSH key only.
  • write_files runs before packages are installed by default. Files owned by users or groups that do not exist yet (such as www-data before Nginx is installed) should get their ownership adjusted in runcmd, as done for the environment file here.
  • The Gunicorn unit is enabled but not started: /srv/myapp/current does 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 is sshd.

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
did it really finish?cloud-init status --longstatus:doneerrors: []sudo less /var/log/cloud-init-output.log1231Not running, not failed.2Any module error is listed here.3Full output of every command.Wait for done before running the first deploydjango-deployment.com
cloud-init status --wait blocks until it finishes, useful in scripts.

cloud-init status --wait blocks until cloud-init finishes, which is useful in scripts that provision and then deploy.

Verification check:

  • status: done with no errors.
  • ssh root@<server-ip> and password logins are refused.
  • UFW allows only SSH, HTTP, and HTTPS.
  • /srv/myapp exists and is owned by deploy.

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.

first boot versus every releasecloud-init (once)users, SSH, firewallsystem packagesdirectories and unitsDeploy tool (every release)application codemigrations, collectstaticrestarts and health checksKeeping the split clean makes servers replaceabledjango-deployment.com
Secrets are delivered by the deploy tool, never baked into user data.

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

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.

Keep going