Deploying a Django App to AWS EC2: My Step-by-Step Setup

The Stack

Here's what's running my app in production:

Nginx        → receives requests from the internet
Gunicorn     → runs my Django app (managed by systemd)
PostgreSQL   → stores all the data
Route 53     → points my subdomain to the server
Certbot      → gives me HTTPS for free

Each piece has one job. That separation is what makes the whole thing manageable.


Step 1: Reuse the Existing Server

I already had an EC2 instance running my personal blog with Nginx, PostgreSQL, and Gunicorn installed. Instead of spinning up a new server, I deployed this app alongside it.

/var/www/personal-blog/          → existing blog
/var/www/document-review-app/    → new app, same server

Two apps, one server. Nginx would later be configured to know which domain goes to which app.


Step 2: Get the Code onto the Server

I cloned my GitHub repo directly onto the server:

cd /var/www
git clone https://github.com/<username>/document-review-app.git
cd document-review-app

Then created a virtual environment and installed dependencies — same as local setup:

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Step 3: Hide the Secrets

Locally, my Django SECRET_KEY and DEBUG setting were sitting in plain code. That's fine for a laptop. Not fine for a public GitHub repo.

I installed python-decouple and moved both into a .env file that never gets committed:

SECRET_KEY=my-actual-secret-key
DEBUG=False

And in settings.py:

from decouple import config

SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)

DEBUG=False matters a lot here — it's what stops Django from showing detailed error pages (with file paths and code) to random visitors.


Step 4: Switch from SQLite to PostgreSQL

SQLite is great for development but isn't built for a real production app. I created a dedicated database and user:

CREATE DATABASE docreview_db;
CREATE USER docreview_user WITH PASSWORD 'strong-password';
GRANT ALL PRIVILEGES ON DATABASE docreview_db TO docreview_user;

Then pointed Django at it through the same .env pattern:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME'),
        'USER': config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST': config('DB_HOST'),
        'PORT': config('DB_PORT'),
    }
}

One gotcha I hit here — newer PostgreSQL versions restrict schema permissions by default, so I had to explicitly grant access:

GRANT ALL ON SCHEMA public TO docreview_user;

Without that, migrations failed with a permissions error even though the user existed.


Step 5: Run Gunicorn as a Permanent Service

python manage.py runserver is for development only — it stops the moment you close your terminal. In production, Gunicorn runs the app, and systemd keeps Gunicorn running forever (even restarting it if the server reboots).

I created a service file:

[Unit]
Description=Gunicorn for Document Review App
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/var/www/document-review-app
ExecStart=/var/www/document-review-app/venv/bin/python3 -m gunicorn --workers 3 --bind unix:/var/www/document-review-app/docreview.sock docreview.wsgi:application

[Install]
WantedBy=multi-user.target

Then started it:

sudo systemctl start docreview
sudo systemctl enable docreview

enable is the important part — it means Gunicorn starts automatically even after a server reboot.


Step 6: Point Nginx at Gunicorn

Gunicorn doesn't talk to the internet directly. Nginx sits in front of it, handling incoming requests and forwarding them through a socket file:

server {
    listen 80;
    server_name docreview.parthiban.dev;

    location /static/ {
        alias /var/www/document-review-app/staticfiles/;
    }

    location /media/ {
        alias /var/www/document-review-app/media/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/var/www/document-review-app/docreview.sock;
    }
}

Static files (CSS, JS, admin styling) and media files (uploaded documents) are served directly by Nginx — it's faster than routing them through Django for no reason.


Step 7: Point a Subdomain at the Server

I added a new subdomain in Route 53, pointing to the same EC2 IP my blog already uses:

Record name:  docreview
Type:         A
Value:        <EC2 public IP>

This gave me docreview.parthiban.dev, fully separate from the main blog, on the same machine.


Step 8: Add HTTPS

The last step — making sure the site loads securely. Certbot handled this in one command:

sudo certbot --nginx -d docreview.parthiban.dev

It automatically updated my Nginx config to serve HTTPS and redirect any HTTP traffic to it.


What I'd Tell a Junior Dev Doing This for the First Time

  • Don't skip the .env step. Committing secrets to GitHub is an easy mistake to make once and a painful one to clean up later.
  • Switch to PostgreSQL before deploying, not after. It's a much smaller change to make early.
  • Use systemd, not a manually run process. If you SSH out and your terminal closes, a manually started server dies with it.
  • Test each layer separately. I confirmed Gunicorn worked on its own before touching Nginx. That made debugging far easier — I always knew which layer the problem was in.

The End Result

https://docreview.parthiban.dev

A Django app, planned from a written spec, built with function-based views, and now running in production with its own database, its own subdomain, and HTTPS — on the same server as my blog.