Deploy Django with uWSGI, Nginx & Ansible

Blog / Server Management · June 16, 2020 · Updated June 10, 2026 · 11 min read
Deploy Django with uWSGI, Nginx & Ansible

To deploy Django with uWSGI and Nginx using Ansible, you describe the whole server as code in an idempotent playbook: a common role installs system packages and creates the app user, a django role clones the code and builds the virtualenv, a uwsgi role renders a uWSGI ini plus a systemd unit that serves Django over a Unix socket, and an nginx role reverse-proxies to that socket and serves static files. Running ansible-playbook site.yml provisions a fresh VM or safely re-applies the exact same state on every run — that repeatability is the whole point of Infrastructure as Code (IaC).

Key takeaways

  • Ansible turns a one-off server setup into reusable, version-controlled code — the same playbook provisions staging, production, and a rebuilt box identically.
  • Use roles (common, django, uwsgi, nginx) so each concern is isolated, reusable across projects, and easy to diff in Git.
  • uWSGI runs as a native systemd service over a Unix socket; Nginx reverse-proxies to it with uwsgi_pass and serves /static/ and /media/ directly from disk.
  • Handlers reload services only when a template actually changes, which keeps re-runs idempotent and downtime near zero.
  • Store secrets with ansible-vault, never in plain group_vars.
  • In 2026, reach for this VM + Ansible approach when you run a small fleet of long-lived servers; choose containers/CI when you need autoscaling or ephemeral infrastructure (more on that below).

Why Ansible for a Django deployment?

Ansible is an agentless automation tool: it connects over SSH and applies a declarative description of the desired state, so re-running a playbook converges the server to that state instead of blindly repeating commands. Compared with a hand-written shell script, an Ansible playbook is idempotent (safe to run repeatedly), self-documenting, and trivial to review in Git.

If you are new to the tool, start with our primers on Ansible for server process automation and the Ansible Galaxy ecosystem. This guide focuses purely on the automation/IaC angle. For the underlying manual steps and performance tuning, see hosting a Django application with Nginx and uWSGI and Django hosting on Nginx with uWSGI for high performance.

Prerequisites

  • A control machine with Ansible 2.16+ installed (pip install ansible or your distro package).
  • One or more target VMs running Ubuntu 22.04 / 24.04 with SSH access and a sudo-capable user.
  • A Django project in a Git repository with a requirements.txt (or pyproject.toml / uv.lock).

Project layout

A clean Ansible repository keeps inventory, configuration, and roles separate:

django-deploy/
├── ansible.cfg
├── inventory.ini
├── site.yml
├── group_vars/
│   ├── all.yml
│   └── vault.yml          # encrypted with ansible-vault
└── roles/
    ├── common/
    │   └── tasks/main.yml
    ├── django/
    │   └── tasks/main.yml
    ├── uwsgi/
    │   ├── tasks/main.yml
    │   ├── handlers/main.yml
    │   └── templates/
    │       ├── env.j2
    │       ├── uwsgi.ini.j2
    │       └── uwsgi.service.j2
    └── nginx/
        ├── tasks/main.yml
        ├── handlers/main.yml
        └── templates/nginx.conf.j2

What each role does

Role Responsibility Key FQCN modules
common System packages, app user, base directories ansible.builtin.apt, ansible.builtin.user
django Clone code, build virtualenv, migrate, collectstatic ansible.builtin.git, ansible.builtin.pip, ansible.builtin.command
uwsgi Render the ini + systemd unit, manage the service ansible.builtin.template, ansible.builtin.systemd_service
nginx Render the server block, enable the site, reload Nginx ansible.builtin.template, ansible.builtin.file

Modern Ansible uses fully-qualified collection names (FQCN) such as ansible.builtin.apt rather than the bare apt shortname — it is explicit, unambiguous, and future-proof.

Configuration and inventory

ansible.cfg points Ansible at your inventory and roles and turns on privilege escalation:

[defaults]
inventory = inventory.ini
roles_path = roles
host_key_checking = True
stdout_callback = yaml
vault_password_file = .vault_pass

[privilege_escalation]
become = True
become_method = sudo

inventory.ini lists the target hosts (a YAML inventory works equally well):

[web]
web1 ansible_host=203.0.113.10 ansible_user=deploy

[web:vars]
ansible_python_interpreter=/usr/bin/python3

Confirm connectivity before going further:

ansible web -m ansible.builtin.ping

Variables and secrets

Put non-secret settings in group_vars/all.yml. Everything else in the playbook references these variables, so deploying a different project is just a matter of changing values here:

---
project_name: mysite
server_name: example.com

app_user: mysite
nginx_group: www-data
app_home: "/srv/{{ project_name }}"
app_dir: "{{ app_home }}/app"
venv_dir: "{{ app_home }}/venv"
socket_path: "/run/uwsgi/{{ project_name }}.sock"
static_root: "{{ app_dir }}/staticfiles"
media_root: "{{ app_dir }}/media"

app_repo: "https://github.com/your-org/{{ project_name }}.git"
app_version: main
uwsgi_processes: 4

# Environment used by Django management commands and the running service.
django_env:
  DJANGO_SETTINGS_MODULE: "{{ project_name }}.settings"
  SECRET_KEY: "{{ vault_secret_key }}"
  DATABASE_URL: "{{ vault_database_url }}"

Keep real secrets in an encrypted vault file so they can live safely in Git:

ansible-vault create group_vars/vault.yml

Inside vault.yml (the file is encrypted at rest):

---
vault_secret_key: "replace-with-a-50-char-random-string"
vault_database_url: "postgres://mysite:s3cret@db.internal:5432/mysite"

The common role: system packages and user

roles/common/tasks/main.yml installs the OS-level dependencies and creates a locked-down service account that the app will run as:

---
- name: Install system packages
  ansible.builtin.apt:
    name:
      - python3-dev
      - python3-venv
      - build-essential
      - libpq-dev
      - git
      - nginx
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Create the application user
  ansible.builtin.user:
    name: "{{ app_user }}"
    system: true
    create_home: true
    home: "{{ app_home }}"
    shell: /usr/sbin/nologin

The django role: code, virtualenv, migrate, collectstatic

This role pulls the latest code, builds the virtualenv with python3 -m venv, then runs migrate and collectstatic. Each task uses become_user to act as the unprivileged app user, and changed_when keeps the reporting honest so re-runs stay idempotent. The git and pip tasks notify the uWSGI restart handler so the service only restarts when code or dependencies actually change:

---
- name: Clone or update the application repository
  ansible.builtin.git:
    repo: "{{ app_repo }}"
    dest: "{{ app_dir }}"
    version: "{{ app_version }}"
  become_user: "{{ app_user }}"
  notify: Restart uWSGI

- name: Create the virtualenv and install dependencies
  ansible.builtin.pip:
    requirements: "{{ app_dir }}/requirements.txt"
    virtualenv: "{{ venv_dir }}"
    virtualenv_command: python3 -m venv
  become_user: "{{ app_user }}"
  notify: Restart uWSGI

- name: Install uWSGI into the virtualenv
  ansible.builtin.pip:
    name: uwsgi
    virtualenv: "{{ venv_dir }}"
  become_user: "{{ app_user }}"

- name: Apply database migrations
  ansible.builtin.command:
    cmd: "{{ venv_dir }}/bin/python manage.py migrate --noinput"
    chdir: "{{ app_dir }}"
  environment: "{{ django_env }}"
  become_user: "{{ app_user }}"
  register: migrate
  changed_when: "'No migrations to apply' not in migrate.stdout"

- name: Collect static files
  ansible.builtin.command:
    cmd: "{{ venv_dir }}/bin/python manage.py collectstatic --noinput"
    chdir: "{{ app_dir }}"
  environment: "{{ django_env }}"
  become_user: "{{ app_user }}"
  register: collectstatic
  changed_when: "'0 static files copied' not in collectstatic.stdout"

Tip: To use the faster uv package manager instead of pip, install uv in the common role and replace the two ansible.builtin.pip tasks with an ansible.builtin.command that runs uv sync --frozen against your uv.lock.

The uwsgi role: ini, environment, systemd unit, and handler

First, the Jinja2 template for the uWSGI ini (roles/uwsgi/templates/uwsgi.ini.j2). It serves Django's WSGI app over a Unix socket and sets die-on-term = true so systemd's SIGTERM stops it cleanly:

[uwsgi]
chdir           = {{ app_dir }}
module          = {{ project_name }}.wsgi:application
home            = {{ venv_dir }}

master          = true
processes       = {{ uwsgi_processes }}
threads         = 2
socket          = {{ socket_path }}
chmod-socket    = 660
chown-socket    = {{ app_user }}:{{ nginx_group }}
vacuum          = true
die-on-term     = true
harakiri        = 60
max-requests    = 5000
buffer-size     = 32768

The running service needs Django's secrets too. Render them from the vault into a root-owned environment file (env.j2) that systemd loads — never bake secrets into the ini or the unit:

DJANGO_SETTINGS_MODULE={{ project_name }}.settings
SECRET_KEY={{ vault_secret_key }}
DATABASE_URL={{ vault_database_url }}

The systemd unit template (uwsgi.service.j2) runs uWSGI in the foreground under systemd supervision — no Supervisor or Emperor mode needed on a modern systemd host. RuntimeDirectory=uwsgi creates and owns /run/uwsgi for the socket, and EnvironmentFile injects the secrets:

[Unit]
Description=uWSGI for {{ project_name }}
After=network.target

[Service]
User={{ app_user }}
Group={{ nginx_group }}
RuntimeDirectory=uwsgi
WorkingDirectory={{ app_dir }}
EnvironmentFile=/etc/{{ project_name }}/env
ExecStart={{ venv_dir }}/bin/uwsgi --ini {{ app_dir }}/uwsgi.ini
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure

[Install]
WantedBy=multi-user.target

The tasks render all three templates and manage the service. no_log: true hides the secret file from output, and daemon_reload: true makes systemd pick up unit changes:

---
- name: Create the service environment directory
  ansible.builtin.file:
    path: "/etc/{{ project_name }}"
    state: directory
    owner: root
    group: "{{ app_user }}"
    mode: "0750"

- name: Render the service environment file (secrets)
  ansible.builtin.template:
    src: env.j2
    dest: "/etc/{{ project_name }}/env"
    owner: root
    group: "{{ app_user }}"
    mode: "0640"
  no_log: true
  notify: Restart uWSGI

- name: Render the uWSGI ini file
  ansible.builtin.template:
    src: uwsgi.ini.j2
    dest: "{{ app_dir }}/uwsgi.ini"
    owner: "{{ app_user }}"
    group: "{{ app_user }}"
    mode: "0644"
  notify: Restart uWSGI

- name: Install the uWSGI systemd unit
  ansible.builtin.template:
    src: uwsgi.service.j2
    dest: "/etc/systemd/system/uwsgi-{{ project_name }}.service"
    mode: "0644"
  notify: Restart uWSGI

- name: Enable and start uWSGI
  ansible.builtin.systemd_service:
    name: "uwsgi-{{ project_name }}"
    enabled: true
    state: started
    daemon_reload: true

The handler in roles/uwsgi/handlers/main.yml restarts the service — but only when one of the tasks above flagged a change:

---
- name: Restart uWSGI
  ansible.builtin.systemd_service:
    name: "uwsgi-{{ project_name }}"
    state: restarted
    daemon_reload: true

The nginx role: reverse proxy and static files

The Nginx server block (roles/nginx/templates/nginx.conf.j2) passes dynamic requests to the uWSGI socket and serves /static/ and /media/ straight from disk:

upstream {{ project_name }}_uwsgi {
    server unix://{{ socket_path }};
}

server {
    listen 80;
    server_name {{ server_name }};
    charset utf-8;
    client_max_body_size 75M;

    access_log /var/log/nginx/{{ project_name }}.access.log;
    error_log  /var/log/nginx/{{ project_name }}.error.log;

    location /static/ {
        alias {{ static_root }}/;
        expires 30d;
        access_log off;
    }

    location /media/ {
        alias {{ media_root }}/;
        expires 30d;
        access_log off;
    }

    location / {
        include     uwsgi_params;
        uwsgi_pass  {{ project_name }}_uwsgi;
    }
}

The tasks render the config, enable it with a symlink, remove the default site, and notify a reload handler:

---
- name: Render the Nginx server block
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: "/etc/nginx/sites-available/{{ project_name }}.conf"
    mode: "0644"
  notify: Reload Nginx

- name: Enable the site
  ansible.builtin.file:
    src: "/etc/nginx/sites-available/{{ project_name }}.conf"
    dest: "/etc/nginx/sites-enabled/{{ project_name }}.conf"
    state: link
  notify: Reload Nginx

- name: Remove the default site
  ansible.builtin.file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: Reload Nginx

- name: Ensure Nginx is enabled and running
  ansible.builtin.systemd_service:
    name: nginx
    enabled: true
    state: started

The matching handler in roles/nginx/handlers/main.yml uses reloaded (not restarted) so existing connections stay alive:

---
- name: Reload Nginx
  ansible.builtin.systemd_service:
    name: nginx
    state: reloaded

The top-level playbook

site.yml ties the roles together in order and escalates privileges with become: true. Tags let you run just one slice of the deploy later:

---
- name: Deploy Django with uWSGI and Nginx
  hosts: web
  become: true
  roles:
    - { role: common, tags: [common, system] }
    - { role: django, tags: [django, app] }
    - { role: uwsgi, tags: [uwsgi, app] }
    - { role: nginx, tags: [nginx, web] }

Handlers are play-scoped: the Restart uWSGI handler defined in the uwsgi role can be notified from the django role because every role's handlers are registered for the whole play.

Running the playbook

Preview every change without touching the server using check mode plus --diff:

# Dry run: report what would change and show file diffs
ansible-playbook site.yml --check --diff

# Apply for real
ansible-playbook site.yml

# Re-deploy only the app code (skip system + nginx work)
ansible-playbook site.yml --tags app

# Re-render just the Nginx config
ansible-playbook site.yml --tags nginx

Because every module is declarative, a second run reports changed=0 when nothing has moved — that is idempotency in action, and it is what makes Ansible safe to run on a schedule or from CI. A code push that changes requirements.txt or a template flips the relevant task to changed, fires its handler, and reloads only the affected service.

uWSGI vs Gunicorn vs uvicorn in 2026

uWSGI is battle-tested but its upstream development has slowed. Many teams now default to Gunicorn (WSGI) or uvicorn (ASGI). Pick based on whether your app is synchronous or needs async/WebSockets:

Server Protocol Best for Notes
uWSGI WSGI Existing setups, fine-grained tuning Mature and feature-rich; slower upstream development
Gunicorn WSGI New synchronous Django apps Simplest config; can run async workers
uvicorn ASGI Async views, Channels, WebSockets, SSE Run behind Gunicorn (uvicorn.workers.UvicornWorker) or standalone

Swapping servers in this playbook is mostly a template change: replace uwsgi.ini.j2 / the systemd ExecStart, and update the Nginx upstream (uWSGI uses uwsgi_pass; Gunicorn and uvicorn use proxy_pass over HTTP). The four-role structure stays identical.

Ansible-to-VMs vs containers in 2026

Configuration management on long-lived VMs and container-based deploys solve different problems — and many teams use both.

Reach for Ansible + VMs when you run a small, stable fleet of servers, want full control of the OS, are modernizing a legacy app, or must provision bare-metal/regulated environments without a container platform.

Prefer containers + CI/CD (Docker, Kubernetes, ECS) when you need horizontal autoscaling, immutable and ephemeral infrastructure, fast rollbacks, and many services deployed independently. Ansible still earns its place there — provisioning the cluster hosts, baking images, and bootstrapping the platform.

If you would rather not run any of this yourself, our team can help via server maintenance services.

Wrapping up

You now have a four-role, idempotent Ansible playbook that takes a bare Ubuntu VM to a running Django site behind Nginx and uWSGI — with secrets in ansible-vault, handlers for zero-fuss reloads, and tags for surgical re-deploys. Commit the whole django-deploy/ directory to Git and you have reproducible infrastructure. To package and share your roles, see our Ansible Galaxy introduction.

Frequently Asked Questions

Should I use uWSGI or Gunicorn for Django in 2026?

Both are solid WSGI servers. uWSGI is more feature-rich and tunable but its upstream development has slowed; Gunicorn is simpler to configure and is the common default for new synchronous Django projects. If your app uses async views, Django Channels, or WebSockets, run an ASGI server such as uvicorn instead.

Is Ansible still worth using when everyone uses Docker?

Yes, for the right job. Ansible excels at configuring long-lived VMs, provisioning the hosts that run your containers, and automating legacy or regulated environments. Containers and Kubernetes win when you need autoscaling and immutable, ephemeral infrastructure. The two are complementary, not mutually exclusive.

How do I make an Ansible playbook idempotent?

Use declarative modules (ansible.builtin.apt, template, systemd_service) instead of raw shell/command, and add changed_when or creates guards when you must shell out. Idempotent tasks report changed=0 on a second run, so the playbook only acts when the real state differs from the desired state.

Where should I store secrets like SECRET_KEY and the database password?

In an ansible-vault-encrypted file (for example group_vars/vault.yml) referenced from your variables. It is safe to commit because it is encrypted at rest; supply the password via --ask-vault-pass or a vault_password_file. Render it into a root-owned, 0640 environment file for the service — never put secrets in plain group_vars or in the repo as cleartext.

How do I deploy new code with minimal downtime?

Re-run the playbook (or just --tags app). The git and pip tasks notify the Restart uWSGI handler, which fires only when something actually changed. uWSGI's master = true with max-requests gives graceful worker recycling, and Nginx is reloaded rather than restarted, so in-flight requests are not dropped.

Can I reuse this playbook for multiple Django projects?

Yes — that is the benefit of roles plus variables. Override project_name, server_name, app_repo, and the paths in group_vars (or per-host vars), and the same common/django/uwsgi/nginx roles deploy a different project. You can also publish the roles to Ansible Galaxy for reuse across teams.

Share this article