Deploy Django on AWS EC2 Step by Step
To deploy Django on AWS EC2 reliably, you need more than python manage.py runserver.
Problem statement
To deploy Django on AWS EC2 reliably, you need more than python manage.py runserver. A production setup has to handle process supervision, reverse proxying, static files, database migrations, TLS, secrets, restarts, and rollback.
The common failure modes are predictable: Gunicorn is running but Nginx cannot reach it, static files return 404, ALLOWED_HOSTS blocks requests, migrations break the release, or HTTPS is enabled but Django still thinks requests are insecure. A good EC2 deployment avoids those issues with a repeatable structure and clear verification at each step.
Quick answer
A solid baseline for a Django AWS EC2 deployment is:
- Ubuntu LTS on EC2
- Python virtualenv
- Gunicorn as the app server
- Nginx as the reverse proxy
- systemd for service management
- PostgreSQL for production data
- optional Redis for caching or background jobs
- Let's Encrypt for TLS
High-level order:
- Provision Ubuntu EC2
- Restrict network access and secure SSH
- Install Python, Nginx, and system packages
- Set up PostgreSQL if you are running it on the instance
- Upload the app into a release-based directory layout
- Create a virtualenv and install dependencies
- Configure Django production settings and secrets
- Run checks, migrations, and
collectstatic - Configure Gunicorn with systemd
- Configure Nginx
- Enable HTTPS
- Verify the deployment
- Keep rollback paths for code and backups for data
Step-by-step solution
1) Provision the AWS EC2 instance
Use Ubuntu 22.04 LTS or 24.04 LTS. For a small app, t3.small or t3.medium is a reasonable starting point. Scale based on memory pressure, worker count, and database load.
In the EC2 security group, allow only:
22from your admin IP range80from the internet443from the internet
If this server will keep local uploads or a local database, size the EBS volume accordingly and enable snapshots.
Point your domain or subdomain to the instance public IP. An Elastic IP is useful if you want a stable address across instance stop/start cycles.
Verify
ssh -i your-key.pem ubuntu@your-server-ip
2) Secure the server before deploying Django
Create a non-root sudo user and use key-based SSH.
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo mkdir -p /home/deploy/.ssh
sudo cp /home/ubuntu/.ssh/authorized_keys /home/deploy/.ssh/
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
Now install basic protections and update packages:
sudo apt update && sudo apt upgrade -y
sudo apt install -y ufw fail2ban
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
If you control all SSH clients and have verified key login works for deploy, disable password auth and root SSH login in /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin no
Then reload SSH:
sudo systemctl reload ssh
Verify
ssh -i your-key.pem deploy@your-server-ip
sudo ufw status
Rollback note: do not close your current SSH session before confirming the new user can log in.
3) Install runtime and system packages
Install Python, venv tools, Nginx, Git, and build dependencies commonly needed by Django packages.
sudo apt install -y python3 python3-venv python3-pip \
nginx git build-essential libpq-dev pkg-config
If your app uses PostgreSQL locally on the server:
sudo apt install -y postgresql postgresql-contrib
If your app uses Redis:
sudo apt install -y redis-server
For many production setups, a managed database is better than a local PostgreSQL install. But for a single EC2 deployment, local PostgreSQL is still common and simpler to start with.
Verify
python3 --version
nginx -v
git --version
4) Set up PostgreSQL if it will run on this EC2 instance
If you are using RDS or another managed database, skip this step and use that connection string in your environment file.
For local PostgreSQL, create the database and user before running Django migrations:
sudo -u postgres psql
CREATE DATABASE myapp;
CREATE USER myappuser WITH PASSWORD 'strongpassword';
ALTER ROLE myappuser SET client_encoding TO 'utf8';
ALTER ROLE myappuser SET default_transaction_isolation TO 'read committed';
ALTER ROLE myappuser SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE myapp TO myappuser;
\q
Verify
psql "postgresql://myappuser:strongpassword@127.0.0.1:5432/myapp" -c '\conninfo'
If local PostgreSQL is only for this app, make sure you also include it in your backup plan.
5) Upload the Django app with a rollback-friendly layout
Create a release-based directory structure:
sudo mkdir -p /srv/myapp/releases /srv/myapp/shared /srv/myapp/shared/static /srv/myapp/shared/media
sudo chown -R deploy:deploy /srv/myapp
Example layout:
/srv/myapp/releases/20260424-120000/srv/myapp/current-> symlink to active release/srv/myapp/sharedfor persistent data
Deploy from Git or a release artifact. Example with Git:
cd /srv/myapp/releases
git clone git@github.com:your-org/your-repo.git 20260424-120000
ln -sfn /srv/myapp/releases/20260424-120000 /srv/myapp/current
If the repo is private, use a deploy key or another reproducible method. Avoid editing code directly on the server.
Verify
ls -l /srv/myapp
6) Create the virtual environment and install dependencies
cd /srv/myapp/current
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
Confirm Gunicorn is installed:
which gunicorn
gunicorn --version
Because this guide keeps .venv inside each release, every new release needs its own dependency install before you switch the /srv/myapp/current symlink. If you roll back to an older release, that release must still have a valid virtualenv and dependencies.
If your project is ASGI-first, Uvicorn may be more appropriate, but Gunicorn is a strong default for standard Django WSGI deployments.
7) Configure Django production settings
Store secrets outside the repo. One simple pattern is an env file referenced by systemd.
Create /etc/myapp.env:
sudo nano /etc/myapp.env
Example:
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=replace-me
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
DATABASE_URL=postgresql://myappuser:strongpassword@127.0.0.1:5432/myapp
REDIS_URL=redis://127.0.0.1:6379/0
Secure it:
sudo chown root:deploy /etc/myapp.env
sudo chmod 640 /etc/myapp.env
Make sure only the Gunicorn service user can read this file; do not make it world-readable.
In Django production settings, make sure you set:
DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
Enable SECURE_SSL_REDIRECT = True only after HTTPS is working correctly, or you can create redirect loops or make the site inaccessible during initial setup.
If you load hosts or origins from environment variables, parse them into Python lists rather than passing a raw comma-separated string directly into Django settings.
For static and media:
STATIC_URL = "/static/"
STATIC_ROOT = "/srv/myapp/shared/static"
MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/myapp/shared/media"
Run a deployment check:
cd /srv/myapp/current
source .venv/bin/activate
python manage.py check --deploy
Verify
Fix any warnings that are relevant to your setup before continuing.
8) Run release commands
Run checks and release commands from the active release:
cd /srv/myapp/current
source .venv/bin/activate
python manage.py check --deploy
python manage.py migrate
python manage.py collectstatic --noinput
Create a superuser only if you actually need admin access:
python manage.py createsuperuser
Rollback note: application rollback is easy; database rollback is not. Before risky schema changes, take a database backup and avoid destructive migrations without a recovery plan.
9) Configure Gunicorn with systemd
Create /etc/systemd/system/gunicorn-myapp.service:
[Unit]
Description=Gunicorn for myapp
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp.env
RuntimeDirectory=gunicorn-myapp
RuntimeDirectoryMode=0755
ExecStart=/srv/myapp/current/.venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/gunicorn-myapp/gunicorn.sock \
config.wsgi:application
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable gunicorn-myapp
sudo systemctl start gunicorn-myapp
sudo systemctl status gunicorn-myapp
Inspect logs if needed:
journalctl -u gunicorn-myapp -n 50 --no-pager
Verify
sudo ls -l /run/gunicorn-myapp/gunicorn.sock
10) Configure Nginx as the reverse proxy
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 20M;
location /static/ {
alias /srv/myapp/shared/static/;
}
location /media/ {
alias /srv/myapp/shared/media/;
}
location / {
include proxy_params;
proxy_pass http://unix:/run/gunicorn-myapp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Verify
curl -I http://example.com
systemctl status nginx
If you get a 502 here, check socket permissions, Gunicorn status, and the Nginx error log before moving on.
11) Enable HTTPS
Install Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
This can update the Nginx config and add HTTP-to-HTTPS redirects.
Verify renewal:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
Once HTTPS is working correctly, enable or keep SECURE_SSL_REDIRECT = True in Django and reload your app service if needed.
Then confirm Django sees secure requests correctly by testing login, forms, and redirects over HTTPS.
Verify
curl -I https://example.com
12) Verify the deployment end to end
Functional checks:
- homepage loads
- admin loads if enabled
- static assets return 200
- forms submit without CSRF errors
- database-backed pages work
Service checks:
systemctl status gunicorn-myapp
systemctl status nginx
sudo ss -ltnp
journalctl -u gunicorn-myapp -n 50 --no-pager
sudo nginx -t
Also verify:
DEBUG=FalseALLOWED_HOSTSmatches the real domain- no secrets are stored in Git
python manage.py check --deployis clean or understood
13) Rollback and recovery plan
Keep the previous release directory. To roll back code:
ln -sfn /srv/myapp/releases/previous-release /srv/myapp/current
sudo systemctl restart gunicorn-myapp
Then verify the site and logs again.
Database rollback is harder. If a migration changed schema incompatibly, switching code back may not be enough. Before risky releases, back up the database and uploaded media.
When to script this
Once you have repeated this process more than a few times, the mechanical parts should become a script or template: package install, directory setup, env file placement, systemd unit creation, Nginx config, release switching, and post-deploy checks. Manual setup is useful first because it makes failures easier to understand. After that, automation reduces drift and rollback mistakes.
Explanation
Gunicorn + Nginx + systemd is a strong baseline for deploying Django on AWS EC2 because it is simple, observable, and widely used.
- Gunicorn runs the Django app process
- Nginx handles client connections, static files, and proxying
- systemd restarts Gunicorn on failure and starts it on boot
This stack is a good default for a single EC2 instance. Move beyond it when you need one or more of:
- higher traffic requiring horizontal scaling
- zero-downtime deploys
- separate worker services
- managed PostgreSQL and Redis
- a load balancer in front of multiple app nodes
Edge cases and notes
Common EC2 deployment issues:
- security group allows
22but not80or443 - Nginx cannot access the Gunicorn socket due to permission mismatch
- DNS still points to an old IP
ALLOWED_HOSTSdoes not include the final hostnamecollectstaticran into the wrong pathCSRF_TRUSTED_ORIGINSis missinghttps://...- redirect loops happen if
SECURE_PROXY_SSL_HEADERis missing behind TLS termination SECURE_SSL_REDIRECTwas enabled before HTTPS was actually working
If your app stores user uploads locally, remember that instance replacement or disk failure can lose them unless you back up media. For larger apps, object storage is often safer than local disk.
This guide is intentionally non-Docker. If your stack is container-based, use a separate deployment path rather than mixing host-level and container-level process management.
Internal links
Before deploying, use the Django Deployment Checklist for Production to confirm settings, secrets, and backup readiness.
If you want more detail on the app server and reverse proxy side, see Deploy Django with Gunicorn and Nginx on Ubuntu.
If your project is ASGI-based, read Deploy Django ASGI with Uvicorn and Nginx.
If you want a simpler HTTPS setup on a small server, see Deploy Django with Caddy and Automatic HTTPS.
FAQ
How much EC2 do I need for a small Django app?
For a small internal app or low-traffic site, t3.small is often enough to start. Watch memory, swap use, response times, and database load before deciding whether to move to a larger instance.
Should I use SQLite on EC2 for production?
Usually no. SQLite can work for very small single-process workloads, but PostgreSQL is the normal production choice for Django on EC2 because it handles concurrency, backups, and operational growth much better.
Can I deploy Django on EC2 without Nginx?
Yes, but it is usually not the best production choice. Nginx gives you better static file handling, TLS integration, buffering, and a stable reverse proxy layer in front of Gunicorn.
How do I update the app after the first deployment?
Create a new release directory, install dependencies in that release, run checks, run migrate and collectstatic, switch the /srv/myapp/current symlink, and restart Gunicorn. Keep the previous release intact so you can roll back code quickly if needed.
What should I back up on a single-server EC2 deployment?
At minimum:
- PostgreSQL database
- uploaded media files if stored locally
- critical config such as env files and Nginx/systemd definitions
- EBS snapshots if you rely on local state
For schema-changing releases, take a fresh database backup before running migrations.