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
.envstep. 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.