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_passand 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 plaingroup_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 ansibleor 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(orpyproject.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.j2What 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 = sudoinventory.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/python3Confirm connectivity before going further:
ansible web -m ansible.builtin.pingVariables 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.ymlInside 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/nologinThe 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, installuvin thecommonrole and replace the twoansible.builtin.piptasks with anansible.builtin.commandthat runsuv sync --frozenagainst youruv.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 = 32768The 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.targetThe 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: trueThe 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: trueThe 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: startedThe 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: reloadedThe 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 nginxBecause 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.