...

How to Deploy a Django Application on Ubuntu with Gunicorn, PostgreSQL, Nginx, and SSL

Martin Klein

Reading time 1 minute

For a production Django deployment, python manage.py runserver alone is not sufficient. In a production setup, Django handles the application logic, PostgreSQL stores the data, Gunicorn runs the Python application, systemd manages the process, and Nginx handles incoming HTTP/HTTPS traffic and serves static and media files.

In this guide, we:

  • Prepared Ubuntu and installed Python, venv, and the required system dependencies;
  • Transferred the Django project to a VPS;
  • Created a virtual environment and installed the dependencies;
  • Installed PostgreSQL and created a dedicated database and user;
  • Moved SECRET_KEY, the database password, DEBUG, and ALLOWED_HOSTS to a .env file;
  • Applied the migrations and collected the static files using collectstatic;
  • Ran Django through Gunicorn on 127.0.0.1:8000;
  • Configured Gunicorn as a systemd service with automatic startup;
  • Configured Nginx as a reverse proxy;
  • Configured Nginx to serve static and media files directly;
  • Set up a domain;
  • Obtained a TLS certificate through Certbot and enabled HTTPS;
  • Covered how to troubleshoot 502 Bad Gateway errors, PostgreSQL issues, static and media file problems, and DisallowedHost errors.

As a result, the Django application runs as a proper production service: it starts automatically after the VPS reboots, uses a dedicated PostgreSQL database, does not store secrets directly in the code, and is accessible to users over HTTPS through Nginx.

From an Empty VPS to Production-Ready Django: What Exactly Are We Building?

Welcome!

Today, we are getting back to coding and command-line work, but this time the task is more challenging.

This time, we will deploy a Django application on Ubuntu and gradually evolve it from a basic project into a proper production setup.

First, let’s take a quick look at Django itself.

Django is a web framework for Python, providing a ready-made toolkit for developing websites, APIs, and other web applications.

It already provides many features that would otherwise have to be implemented manually:

  • Handling URLs and user requests;
  • Working with a database through an ORM;
  • Using templates;
  • Creating an admin panel;
  • Managing users and authentication;
  • Integrating middleware and other application components.

Simply put, Python gives us the language, while Django provides a ready-made foundation for building a web application.

For example:

However, the fact that a project is built with Django and runs locally does not mean it is ready for public use.

Our goal is to go all the way from an empty Ubuntu server to a setup that resembles a proper production deployment.

Suppose we already have a working Django project.

It may run perfectly well on a local computer using the command: python manage.py runserver

At first glance, a perfectly reasonable question arises: “If the site already works, why do we need to add PostgreSQL, Gunicorn, systemd, Nginx, and SSL?”

Because local development and a public server serve different purposes.

On our own computer, we need to be able to start the project quickly, see our changes, and continue writing code.

A production server has a different set of requirements. We want:

  • The application to survive a VPS reboot;
  • The database to store production data reliably;
  • The site to be accessible through a domain name;
  • The connection to be secured with HTTPS;
  • Static and media files to be served correctly to users;
  • The application to restart automatically after a failure;
  • No need to expose Django’s internal port publicly.

The resulting stack will therefore have multiple layers.

However, there is no need to be intimidated by the number of components. Each one has a clear role, and we will add them gradually as we proceed.

Why python manage.py runserver Is Not Enough

Let’s start with the usual: python manage.py runserver

This command starts Django’s built-in development server.

And the key word here is development.

It was designed primarily for development: to quickly start a project, view a page, test the code, and continue working.

For example:

This is perfectly suitable for local development.

However, the requirements change on a public server.

We now need to handle real user requests, manage the application process properly, and avoid relying on an open SSH session.

Suppose we simply logged in to the VPS and ran: python manage.py runserver 0.0.0.0:8000

The site may load.

But once we close the terminal, the process stops—and the application disappears with it.

Even if we use a workaround to run it in the background, another question remains: what will restart Django after a server reboot or process failure?

Moreover, Django itself explicitly considers runserver a development tool, not a production server.

Therefore, in our setup, it will be replaced by Gunicorn.

In simple terms:

  • Runserver → convenient for developers
  • Gunicorn → designed to run Python web applications in production

However, Gunicorn cannot handle every task on its own either.

This is where the need for the remaining components becomes clear.

Component Responsibilities: Django, Gunicorn, PostgreSQL, systemd, and Nginx

Let’s look at the entire stack as a team in which each component has its own role.

Django — we have already discussed it; it is the application itself.

This is where you will find:

  • Business logic;
  • Routes;
  • Models;
  • Templates;
  • API;
  • The admin panel;
  • Data handling.

In other words, Django answers the question: “What should the website do when a user sends a request?”

However, the framework itself does not need to handle every infrastructure task associated with that request.

Next is PostgreSQL. It will store the application’s persistent data.

For example:

  • Users
  • Orders
  • Posts
  • Settings
  • Comments

Django communicates with PostgreSQL through the ORM, while the database handles the actual storage of these records.

Instead of this setup:

We will end up with:

We will take a closer look a little later at why we will use PostgreSQL in production instead of keeping the default SQLite database.

Next comes Gunicorn. It acts as an intermediary layer between the external web server and Django.

It will run our Python application and accept the requests that Django then processes.

To use a simple analogy, Django is like a restaurant kitchen where orders are actually prepared.

In this analogy, Gunicorn is the waiter who takes an order and delivers it to the kitchen.

But we still would not send customers directly to the waiter through the back door.

Another layer will be placed in front of it — Nginx.

Nginx will serve as the external entry point.

It will accept user requests on the standard ports:

  • 80
  • 443

It will then forward dynamic requests to Gunicorn.

Nginx will also handle:

  • Domain configuration;
  • HTTPS;
  • Static files;
  • Media files;
  • Proxy headers.

In other words:

 Gunicorn does not need to be exposed directly to the internet at all.

One participant remains— systemd.

Its role is not to process HTTP requests or interact with the database.

It will monitor the Gunicorn process itself.

For example:

Or if Gunicorn stops unexpectedly, systemd allows you to manage the service centrally and view its status and logs.

The component roles can be summarized in a small table:

ComponentResponsibility
DjangoApplication logic
PostgreSQLPersistent data
GunicornRunning Django as a production web application
systemdManaging and automatically starting the Gunicorn process
NginxExternal HTTP/HTTPS traffic, proxying, static files, and media files

When viewed as separate components, the stack may seem extensive.

But once the responsibilities are broken down, everything becomes much clearer.

What the Final Architecture Will Look Like

Now, let’s assemble all the components into a single request flow.

The user opens: https://example.com

The request reaches Nginx.

If it is a standard application request, Nginx forwards it to Gunicorn:

Django executes the required logic, reads or modifies data in PostgreSQL as needed, and sends the response back through the same chain.

However, static and media files follow a slightly different path.

For example, a request for: /static/css/style.css does not need to be sent to Django at all.

Nginx can serve such files directly:

We will configure the same behavior for user-uploaded media later.

The Gunicorn process will be managed by systemd:

This is the architecture we will work toward step by step.

We will not install every component with one enormous block of commands. First, we will prepare Ubuntu, then transfer the project, isolate its Python dependencies, connect PostgreSQL, and only then begin building the production stack around Django.

The first practical step is the simplest: preparing the VPS itself for deployment.

Preparing Ubuntu for Deployment

For this guide, I’m once again using a VPS from ServerMall. Simply because I’m already used to working with them. The interface is familiar, and servers can be deployed quickly.

However, virtually any VPS will work as long as it runs Ubuntu, has a public IP address, and allows SSH access with sudo privileges. Use a provider you are familiar with; if you do not have one, you can either try ServerMallor choose any other major provider. 

Now, back to the topic. Here is our starting point:

From there, we will gradually turn the bare server into a production environment for Django.

Checking the System and Updating Packages

First, let’s check which system we are working with:

cat /etc/os-release

The output will show the Ubuntu version, its codename, and other basic information.

For example:

NAME=”Ubuntu”

VERSION=”24.04 LTS (Noble Numbat)”

VERSION_ID=”24.04″

You can also check the architecture: dpkg --print-architecture

On most standard VPS instances, you will see: amd64

Now let’s update the package index: sudo apt update

APT will retrieve up-to-date information about available packages from the configured Ubuntu repositories.

If the server has not been updated in a while, you can also install the available updates: sudo apt upgrade -y

Why is it best to do this now?

Next, we will install Python, PostgreSQL, Nginx, and other components. It is much easier to build the environment on an up-to-date system than to discover outdated dependencies or long-pending updates in the middle of deployment.

Major system updates sometimes require a reboot. You can check whether one is required as follows:

 test -f /var/run/reboot-required && echo "Reboot required"

If the system does require a reboot, it is best to do so now: sudo reboot, and then reconnect via SSH.

The foundation is now in place.

Installing Python, pip, venv, and System Dependencies

Django is written in Python, so the first things we need are the Python interpreter and its supporting tools.

Install the basic set:

 sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libpq-dev

Several unfamiliar packages appear here, so let’s review what each one is used for:

PackagePurpose
python3The Python interpreter
python3-pipInstalling Python packages
python3-venvCreating virtual environments
python3-devPython header files needed to build certain dependencies
build-essentialCompiler and essential build tools
libpq-devPostgreSQL development libraries that may be required by the Python driver

At first glance, python3-dev, build-essential, and libpq-dev may seem unnecessary.

However, not all Python packages are written entirely in Python.

Some dependencies contain C components or must be built for the target system during installation. If the required tools are unavailable, a routine: pip install ... ,may unexpectedly fail with a compilation error.

It is therefore more convenient to prepare the server in advance.

Let’s check the versions:

python3 --version

pip3 --version

And let’s make sure the venv module is available: python3 -m venv --help

If the help output appears, everything is working correctly.

We will return to the virtual environment itself separately. For now, it is enough to understand its purpose: we will not install all Django dependencies globally in Ubuntu.

Later, we will create a separate Python environment specifically for our project.

Something like this:

This reduces interference between the project’s dependencies, the system environment, and other Python applications.

Creating the Project Directory

Now we need to decide where the application code will reside.

For a production project, it is best to choose a separate, clearly named directory in advance rather than putting files wherever convenient.

For example, let’s create the directory: sudo mkdir -p /var/www/django-app

Assign ownership of the directory to the current user: sudo chown -R $USER:$USER /var/www/django-app

And navigate to the directory: cd /var/www/django-app

Let’s check: pwd

Expected: /var/www/django-app

Why /var/www?

This is not a strict Django requirement. The project can just as easily reside in another directory.

However, /var/www is traditionally used for web project files, making the server structure fairly self-explanatory:

If you need to log in to the server again six months from now, you will be less likely to ask yourself, “Where did I put the project?”

At this point, we have not moved the code yet.

We have only prepared the location where it will be placed in the next chapter.

Moving the Django Project to the Server

At this stage, our task is fairly straightforward: copy the code to the VPS, verify that the project structure is as expected, and prepare to create a virtual environment.

It is important not to rush into running the project. First, make sure that the entire project has been transferred to the server, not just some of the files.

Where to Store the Production Project

In the previous chapter, we created the following directory: /var/www/django-app

This is where we will put the code.

This is not a Django requirement, but simply a convenient way to organize files.

You can think of the server structure as follows:

Later, this same project directory will also contain:

In other words, all the application’s main components will be kept together in one clearly organized location.

This is especially convenient when more than one project is running on the VPS.

For example:

Each service gets its own directory, so months later, you will not have to remember exactly what is stored in /home/ubuntu/test_final_new_2.

Uploading the Code to the VPS

There are several ways to transfer the project.

1. If the code is stored in a Git repository, the most convenient option is to clone it directly onto the server.

First, install Git if it is not already installed: sudo apt install -y git

Navigate to the prepared directory: cd /var/www/django-app

If the directory is empty, you can clone the project here: git clone https://github.com/USER/PROJECT.git .

The dot at the end means: “Place the repository contents directly in the current directory instead of creating another nested directory.”

Without the dot, it would look something like this: /var/www/django-app/PROJECT/

And with the dot:

2. If you are using a private repository, it is best not to include your username, password, or access token directly in the command.

For a production server, an SSH key or deploy key is typically used.

For example: git clone [email protected]:USER/PROJECT.git .

3. If Git is not used at all, you can transfer the project via scp, SFTP, or any other familiar method.

For example, from the local computer:

 scp -r ./django-project/* [email protected]:/var/www/django-app/

Which method you choose is not important.

The key is to ensure that the VPS ultimately contains the project source code, rather than the local virtual environment, Python cache, or any secrets inadvertently copied from the developer’s computer.

This is why the following are typically not committed to Git:

venv/

__pycache__/

.env

*.pyc

These items are either recreated on the server or contain data that should not be stored in the repository at all.

Checking the Project Structure

The code has been uploaded. For now, we will not immediately run pip install or migrate.

First, let’s see what we actually received: ls -la

For a typical Django project, we expect to see something like this:

manage.py

requirements.txt

project/

app/

Directory names may vary, but the key reference point is the file: manage.py

It is usually located in the root directory of the Django project and is used to run administrative commands:

python manage.py migrate

python manage.py collectstatic

python manage.py createsuperuser

There should also be a file listing the project’s dependencies.

Most commonly: requirements.txt

You can check it as follows: cat requirements.txt

For example, it may include:

  • Django
  • gunicorn
  • psycopg
  • python-dotenv

It may also include other libraries specific to the project.

Now, let’s look inside the Django project directory.

For example: ls project/

It typically contains:

  • settings.py
  • urls.py
  • wsgi.py
  • asgi.py

We will be particularly interested in wsgi.py later.

 Gunicorn will connect to the Django application through this file.

At this stage, it is useful to identify the Django project’s name in advance.

For example, consider the following structure:

In this case, Gunicorn will later be started by referencing: myproject.wsgi

This small detail often causes errors such as ModuleNotFoundError when someone else’s project name has simply been copied into the configuration.

Creating a Python Virtual Environment

The project code is already on the server. The next step is to set up a dedicated Python environment for it.

This is important because a production server rarely remains dedicated to a single application for long. Today it may host one Django project; tomorrow, a second one may be added, along with a different set of libraries and dependency versions. Before you know it, the server has developed an ecosystem of its own.

If you install everything globally in the system Python environment, you soon run into a familiar situation:

  • Project A → Django 5.x
  • Project B → a different version of Django
  • Project C → its own set of libraries

And they all have to coexist in the same shared environment.

To avoid forcing all Python packages into a single shared space on the server, use venv.

Why Django Needs a Separate venv

A venv is a Python virtual environment.

It creates a separate directory containing its own:

  • Python packages;
  • Executable files;
  • Project dependencies;
  • Django installation;
  • Gunicorn;
  • PostgreSQL driver;
  • Other libraries listed in requirements.txt.

It is important to understand: venv is neither a virtual machine nor a container.

It does not isolate the network, processes, or the entire file system.

It simply separates one project’s Python dependencies from the system Python installation and other projects:

As a result, updating a library in one project should not unexpectedly break another.

This is especially useful in production, as it makes the project environment more predictable and reproducible.

Alternatively, the application can be packaged in a container, but as a matter of professional interest, we will use a “traditional” installation with a virtual environment.

Create and activate the environment

Navigate to the project directory: cd /var/www/django-app

Now let’s create a virtual environment: python3 -m venv venv

After that, the following directory will be created within the project: /var/www/django-app/venv/

This is where Python and the packages for this project will reside.

Activate the environment: source venv/bin/activate

After activation, the terminal prompt typically changes to something like this: (venv) ubuntu@server:/var/www/django-app$

This is a useful visual indicator that the python and pip commands now refer to our virtual environment.

Let’s check:

which python

which pip

The paths should look something like this:

/var/www/django-app/venv/bin/python

/var/www/django-app/venv/bin/pip

This means that we are now using the project’s isolated environment rather than the system Python installation.

You can exit it later using the command: deactivate

For now, keep the venv active.

Installing Project Dependencies

Before installing packages, it is a good idea to update pip itself within the virtual environment: python -m pip install --upgrade pip

Now install the project dependencies: pip install -r requirements.txt

pip will read the requirements.txt file and install the libraries listed there in the current venv.

For example, it may contain:

  • Django
  • gunicorn
  • psycopg
  • python-dotenv

Alternatively, versions can be pinned more precisely:

  • Django==5.2.6
  • gunicorn==23.0.0
  • psycopg==3.2.9

Pinning versions makes deployment more predictable.

Without it, run the following command: pip install -r requirements.txt

may theoretically install different library releases today and six months from now.

This is not always desirable in production.

If requirements.txt does not include Gunicorn for some reason, you can install it separately: pip install gunicorn

To work with PostgreSQL, you need a compatible driver, for example: pip install psycopg

However, it is better to list all required dependencies in requirements.txt rather than installing them manually “from memory.”

Otherwise, the server starts running, but a month later no one remembers which additional packages were installed on it.

Checking Django and Gunicorn

After installation, we won’t move on to PostgreSQL just yet.

First, let’s make sure that the required components have actually been installed in the venv.

Let’s check Django: python -m django --version

The installed version should be displayed.

Now Gunicorn: gunicorn --version

And the package list itself: pip list

For a more precise check, you can determine where Gunicorn is being launched from: which gunicorn

Expected: /var/www/django-app/venv/bin/gunicorn

This is an important detail.

If the command returns something like: /usr/bin/gunicorn, then we are most likely using the system-wide installation rather than the version from the virtual environment.

In production, this can later lead to a rather amusing situation: when started manually, the project uses one version of Python and its libraries, while systemd uses a completely different one.

It is therefore best to verify now that the entire stack is indeed installed in the correct venv.

Setting Up PostgreSQL for Django

Now it is time to add something that most real-world Django projects cannot do without for long: a full-fledged database.

By default, a new Django project typically uses SQLite. This is convenient for local development: no separate database server is required, everything is stored in a single file, and you can get started in just a minute.

Production, however, is a different story.

Why You Should Avoid Using SQLite in Production

SQLite stores the entire database in a single file.

For example: db.sqlite3

For local development, this is actually an advantage.

You can quickly start, move, delete, and recreate the project without separately configuring a database management system.

However, once real users, concurrent requests, migrations, and sustained workloads come into play, this approach becomes limiting.

PostgreSQL is better suited to production scenarios because it is a full-fledged server-based database management system.

It can reliably handle:

  • Multiple concurrent connections;
  • Transactions;
  • Locking;
  • Complex queries;
  • Indexes;
  • Large volumes of data;
  • Separate users and access permissions.

Put simply:

  • SQLite → convenient for getting started
  • PostgreSQL → more convenient for continued use in production

This does not mean that SQLite is a bad database.

It simply has a different sweet spot.

If the Django project is a small internal tool with minimal load, SQLite may continue to work perfectly well.

However, for a public production application, PostgreSQL generally provides much more room for growth.

The setup will look like this:

Django will not connect to the database using a PostgreSQL administrator account.

As in the previous article on MySQL, we will create a separate database and a separate account specifically for the application.

Installing PostgreSQL

PostgreSQL is available in the standard Ubuntu repositories.

Install the server and additional utilities:

sudo apt update

sudo apt install -y postgresql postgresql-contrib

After installation, PostgreSQL is usually started automatically by systemd.

Let’s check: sudo systemctl status postgresql --no-pager

We are interested in Active: active (exited) or the status of the running PostgreSQL cluster, depending on the version and systemd unit configuration.

You can also check whether the server itself is ready: sudo -u postgres pg_isready

We expect a response similar to the following: /var/run/postgresql:5432 – accepting connections

This check confirms that PostgreSQL is accepting connections.

Let’s see which port is in use: sudo ss -lntp | grep 5432

By default, PostgreSQL runs on port: 5432

However, we do not need this port to be externally accessible at this point.

Django and PostgreSQL are running on the same VPS, so the database can remain accessible only locally.

Create a Database and a Dedicated User

After PostgreSQL is installed, an administrative account is created on the system: postgres

This is both the Linux user used to administer PostgreSQL and the administrative role of the same name within the DBMS itself.

We will not use it for day-to-day Django operations.

Log in to PostgreSQL: sudo -u postgres psql

The prompt will change to something like: postgres=#

Now let’s create a database for our project: CREATE DATABASE django_db;

And a separate user: CREATE USER django_user WITH PASSWORD 'STRONG_PASSWORD';

Schematically, the setup looks like this:

The postgres user remains reserved for the administrator, while the application gets its own account.

This is useful for the same reason we did not run the application as the MySQL root user: if the application is ever compromised, its account should not automatically have full control over all PostgreSQL databases on the server.

Grant permissions only on the required database

Now let’s grant django_user access to our database:

 GRANT ALL PRIVILEGES ON DATABASE django_db TO django_user;

However, there is a nuance with PostgreSQL.

Permissions on the database itself and permissions on the objects within it are not always the same.

During migrations, Django needs to create tables, indexes, and other objects.

Therefore, let’s connect to the required database: \c django_db

And grant the user access to the public schema: GRANT ALL ON SCHEMA public TO django_user;

This is especially important in modern PostgreSQL configurations, because a regular user may not have sufficient permissions to create objects in the public schema.

Django can now run migrations and create its tables.

Optionally, you can make the user the database owner: ALTER DATABASE django_db OWNER TO django_user;

This is often convenient for a dedicated application database: django_user becomes the owner of that database without receiving administrative privileges for the entire PostgreSQL Server.

After that, exit: \q

The key point is:

  • Postgres → administers the PostgreSQL server
  • Django_user → works only with django_db

The roles are no longer mixed.

Testing the Connection

Now, before we start configuring Django, let’s verify the account.

Let’s connect locally: psql -h 127.0.0.1 -U django_user -d django_db

PostgreSQL will prompt you for the password.

If everything is configured correctly, the prompt will look something like this: django_db=>

Let’s check the current user: SELECT current_user;

Expected: django_user

And the current database: SELECT current_database();

This gives us: django_db

For a final check, you can temporarily create a small table:

CREATE TABLE connection_test (

id SERIAL PRIMARY KEY,

message TEXT NOT NULL

);

Add a record:

INSERT INTO connection_test (message)

VALUES (‘PostgreSQL works’);

And let’s read it: SELECT * FROM connection_test;

If the data is returned, the user can indeed create tables and work with the database.

After testing, you can delete the test table: DROP TABLE connection_test;

The database is now fully ready.

However, Django does not know anything about it yet. Moreover, we definitely do not want to hard-code the PostgreSQL password, SECRET_KEY, and production settings in settings.py and then accidentally commit all of this to Git.

Therefore, the next step is to move secrets and environment settings out of the code and use them to connect Django to PostgreSQL.

Removing Secrets from settings.py

Why You Should Not Store SECRET_KEY and the Database Password Directly in Code

Django stores critical project settings in settings.py.

For example:

SECRET_KEY = “some-secret-value”

DEBUG = True

and the database configuration:

DATABASES = {

“default”: {

“ENGINE”: “django.db.backends.postgresql”,

“NAME”: “django_db”,

“USER”: “django_user”,

“PASSWORD”: “STRONG_PASSWORD”,

“HOST”: “127.0.0.1”,

“PORT”: “5432”,

}

}

Technically, Django will work perfectly well.

The problem arises later.

Suppose the project is stored in Git.

You run:

git add .

git commit -m "production settings"

git push

and the PostgreSQL password is pushed to the repository along with the code.

If the repository is public, the problem is obvious.

But even in a private repository, it is better to keep secrets separate from the code. Developers, CI/CD systems, third-party services, and other systems may have access to Git even though they do not need to know the production database password.

The same applies to SECRET_KEY.

Django uses it for cryptographic operations within the framework. It is therefore not merely a decorative string that can safely be published with the code.

It is more convenient to keep them separate:

The same code can then be used across different environments:

Development

→ its own database

→ DEBUG=True

Staging

→ a different database

→ its own domains

Production

→ production PostgreSQL

→ DEBUG=False

→ the live domain

The code remains the same, while the values change depending on the environment.

This is exactly why we will create a .env file.

Create a .env file

Navigate to the project directory: cd /var/www/django-app

Let’s create the file: nano .env

Add the values for our production environment:

SECRET_KEY=CHANGE_ME_TO_A_LONG_RANDOM_VALUE

DEBUG=False

DB_NAME=django_db

DB_USER=django_user

DB_PASSWORD=STRONG_PASSWORD

DB_HOST=127.0.0.1

DB_PORT=5432

ALLOWED_HOSTS=example.com,www.example.com

The secrets are now stored separately from settings.py.

However, the .env file itself must also be protected.

First, it should not be committed to Git.

Let’s check .gitignore: nano .gitignore

And let’s add: .env

If the .env file has already been added to the repository, adding it to .gitignore is not enough: Git will continue tracking the file until it is explicitly removed from the index.

Second, let’s restrict permissions on the server itself: chmod 600 .env

Now only the file owner can read and modify it.

Passing PostgreSQL parameters via environment variables

Now we need to configure Django to read these values.

The approach depends on the project.

For example, if python-dotenv is already listed in requirements.txt, you can use it to load the .env file.

At the beginning of settings.py:

import os

from pathlib import Path

from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent

load_dotenv(BASE_DIR / “.env”)

You can then retrieve the values using:

os.getenv(“VARIABLE_NAME”)

For example: SECRET_KEY = os.getenv(“SECRET_KEY”)

Now configure PostgreSQL:

DATABASES = {

“default”: {

“ENGINE”: “django.db.backends.postgresql”,

“NAME”: os.getenv(“DB_NAME”),

“USER”: os.getenv(“DB_USER”),

“PASSWORD”: os.getenv(“DB_PASSWORD”),

“HOST”: os.getenv(“DB_HOST”, “127.0.0.1”),

“PORT”: os.getenv(“DB_PORT”, “5432”),

}

}

Note: os.getenv(“DB_HOST”, “127.0.0.1”)

The second value is the fallback.

If the DB_HOST variable is not set, Django uses: 127.0.0.1

We used the same approach for the PostgreSQL port.

As a result, settings.py no longer contains the actual password.

It only specifies: “Retrieve the password from the environment.”

The actual value is stored on the server.

Configuring DEBUG and ALLOWED_HOSTS

Now let’s look at two more production settings.

First: DEBUG

On the local machine, the following setting is typically used: DEBUG = True

This is convenient: Django displays detailed error pages with tracebacks, variables, and other information for developers.

In production, this information should not be shown to visitors.

Therefore, we added the following to .env: DEBUG=False

However, there is a small catch.

If you use: DEBUG = os.getenv(“DEBUG”)

value: False

will be received as a string, and a non-empty string in Python is considered truthy.

In other words, you could specify False and unexpectedly get behavior similar to having the flag enabled.

Therefore, it is better to convert the value explicitly: DEBUG = os.getenv(“DEBUG”, “False”).lower() == “true”

Now:

True  → True

true  → True

False → False

The next setting: ALLOWED_HOSTS

Django uses it to determine which hostnames the application should serve requests for.

In .env: ALLOWED_HOSTS=example.com,www.example.com

And in settings.py:

ALLOWED_HOSTS = [

host.strip()

for host in os.getenv(“ALLOWED_HOSTS”, “”).split(“,”)

if host.strip()

]

The resulting string: example.com,www.example.com

becomes: [“example.com”, “www.example.com”]

If the actual domain has not yet been connected, you can temporarily specify the required host separately for testing.

However, the setting ALLOWED_HOSTS = [“*”], should not be left as a permanent production setting just to “make everything work.”

We will return to ALLOWED_HOSTS when we connect the domain.

Verifying That Django Can Access the Environment Variables

Now we need to do more than just save the files—we must make sure that Django has actually received the values.

The virtual environment should be active:

cd /var/www/django-app

source venv/bin/activate

First, you can check the Django configuration itself: python manage.py check

If everything is working correctly, you should see: System check identified no issues

Now let’s test the PostgreSQL connection through Django itself.

Let’s open the shell: python manage.py shell

Then run:

from django.db import connection

connection.ensure_connection()

print(connection.settings_dict[“NAME”])

If the connection is established, we will see: django_db

After that: exit()

There is also a simpler practical test, which we will perform shortly anyway: python manage.py migrate

If Django connects to PostgreSQL successfully and can work with migrations, the database settings are being read correctly.

However, we will leave the migrations for the next chapter.

For now, the key is to verify three things:

There is one more important consideration.

We have protected the secrets from Git, but Gunicorn will later be started by systemd rather than from our current shell session.

This means systemd must also know where to load these variables from.

We will return to this when we create gunicorn.service.

For now, Django knows which production database to use and has access to the main environment settings.

The next step is to prepare the application for its first production run: apply the migrations, collect the static files, and check the project before configuring Gunicorn.

Preparing Django for Production

As mentioned earlier, there are three required steps:

  • Apply the migrations;
  • Collect the static files;
  • Verify that Django itself starts without errors.

If necessary, we will also create an administrative user.

Running Migrations

Let’s start with the database.

Django stores model definitions in code, but simply defining a class in models.py is not enough to create the corresponding table in PostgreSQL.

That is what migrations are for.

In simplified terms:

If the project already includes migrations, we simply need to apply them to the production database.

Activate the virtual environment:

cd /var/www/django-app

source venv/bin/activate

Let’s check which migrations Django recognizes: python manage.py showmigrations

Unapplied migrations will be marked like this: [ ]

Applied migrations will be marked like this: [X]

Now run: python manage.py migrate

Django will connect to PostgreSQL using the settings specified in .env and create the required tables.

Successful output will include lines such as:

Applying contenttypes.0001_initial… OK

Applying auth.0001_initial… OK

Applying sessions.0001_initial… OK

If the migrations fail with a PostgreSQL connection error, return to the previous chapter and check DB_NAME, DB_USER, the password, host, and user permissions.

If we see OK, it means the following setup:

is already working in practice, not just in the configuration.

Create a superuser if needed

If the project uses the standard Django admin interface, you can create an administrator account right away.

To do this: python manage.py createsuperuser

Django will prompt you for a username, email address, and password.

After that, the account will be able to log in to /admin/, once the site is accessible through Nginx.

Creating a superuser is not always required.

If the project does not use Django Admin at all, or if administrator accounts are created another way, you can safely skip this step.

There is a subtle but important point here: a Django superuser is neither the Linux root user nor a PostgreSQL user.

It is a separate level of access within the web application itself.

This means that we gradually end up with several distinct roles:

Linux user

→ manages files and processes on the VPS

PostgreSQL user

→ works with the database

Django superuser

→ manages data through Django Admin

They have different names and permissions, and these levels should not be conflated.

Collecting static files with collectstatic

The next step often raises questions for those deploying Django to production for the first time.

The project may include CSS, JavaScript, Django admin images, and other static files.

During development, Django can conveniently handle these files itself.

In production, we will take a different approach: we will collect the static files in a separate directory, and Nginx will later serve them directly.

First, make sure that STATIC_ROOT is defined in settings.py.

For example:

STATIC_URL = “/static/”

STATIC_ROOT = BASE_DIR / “staticfiles”

Now run: python manage.py collectstatic

Django will find the static files for all applications and place them in a single directory: /var/www/django-app/staticfiles/

Conceptually, this is what happens:

If Django prompts for confirmation before overwriting files, you can use the following command for automated production deployment: python manage.py collectstatic --noinput

After completion, you can check the directory: ls -lah staticfiles/

It is important to understand that collectstatic does not create CSS or JavaScript out of thin air.

It collects the existing static files from the project and installed Django applications in one place, making them easy for the web server to serve.

This does not apply to media files.

User uploads such as avatars, documents, and photos will be stored separately later.

Testing the Application Locally

Before moving on to Gunicorn, we will perform one final check of Django itself.

First: python manage.py check

Expected result: System check identified no issues

This command checks the project configuration and helps catch certain issues before startup.

But you can go a step further and temporarily run Django’s built-in server locally: python manage.py runserver 127.0.0.1:8000

Note that we deliberately use 127.0.0.1 rather than 0.0.0.0.

At this point, we do not need to expose the development server to the internet.

We simply want to verify that the project starts successfully on the VPS itself.

In another SSH session, verify it by running: curl http://127.0.0.1:8000

If the application returns HTML or the expected HTTP response, Django can run with the current settings.

After testing, stop runserver: Ctrl + C

We will not use it again in production.

Django itself is now ready:

However, the application is still being started manually using the development server.

The next step is to replace the development server with Gunicorn and see how Django runs through a production WSGI server.

Running Django with Gunicorn

Now, between Nginx and Django itself, there will be Gunicorn — a standalone WSGI server that will run the application and accept requests from the web server.

Why runserver Is Not Used in Production

The command python manage.py runserver is intended primarily for development.

It is convenient because it:

  • Starts the project quickly;
  • Displays errors;
  • Automatically restarts when the code changes;
  • Requires no separate configuration.

However, a server is expected to behave differently in production.

The application must:

  • Reliably handle real requests;
  • Support multiple worker processes;
  • Be managed by a separate system service;
  • Work properly behind a reverse proxy;
  • Not depend on an open terminal session.

This is where runserver’s role ends.

How Gunicorn Works with Django

As mentioned earlier, Gunicorn is a WSGI HTTP server for Python applications.

But particularly attentive readers may have noticed another acronym here— WSGI.

Put simply, WSGI is a standard interface between a Python web application and the server that runs it.

In other words, Gunicorn does not replace Django.

It provides an environment in which Django can receive HTTP requests.

Conceptually:

A standard Django project already contains the following file: myproject/wsgi.py

We checked for it earlier, after uploading the project to the VPS.

It usually contains a configuration similar to the following:

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault(

“DJANGO_SETTINGS_MODULE”,

“myproject.settings”

)

application = get_wsgi_application()

We are particularly interested in the object: application

Gunicorn then connects to this application.

Therefore, the startup command usually ends with: myproject.wsgi:application

Here:

  • myproject.wsgi is the Python module;
  • application is the WSGI application within that module.

If your Django project directory has a different name, you must change this name accordingly.

For example: shop/wsgi.py means: shop.wsgi:application

You definitely should not blindly copy someone else’s project name here.

Testing Gunicorn manually

We have already installed Gunicorn along with the Python dependencies.

Activate the virtual environment:

cd /var/www/django-app

source venv/bin/activate

First, let’s make sure that the version from our venv is the one being run: which gunicorn

Expected path: /var/www/django-app/venv/bin/gunicorn

Now let’s start Django manually: gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application

If your project has a different name, replace myproject with your own.

For example: gunicorn --bind 127.0.0.1:8000 shop.wsgi:application

After starting, Gunicorn will display messages similar to the following:

Starting gunicorn

Listening at: http://127.0.0.1:8000

Using worker

Booting worker

The application is now running not through manage.py runserver, but through Gunicorn.

Let’s open a second SSH session and check: curl http://127.0.0.1:8000

If you receive the page HTML or the expected application response, the chain is working:

You can also view only the HTTP headers: curl -I http://127.0.0.1:8000

For example, the following response would be acceptable: HTTP/1.1 200 OK

or another expected status code for that specific route.

After verification, stop the manual run: Ctrl + C

For now, Gunicorn still depends on our SSH session.

systemd will address this shortly.

Choosing Between a Unix Socket and a Local TCP Port

Now we need to decide how Nginx will communicate with Gunicorn.

There are two common options.

The first is a local TCP port:

The second is a Unix socket:

Both options work.

Let’s start with the first option: a local TCP port.

For example: gunicorn –bind 127.0.0.1:8000 myproject.wsgi:application

The key part here is 127.0.0.1.

Gunicorn listens only on the server’s loopback interface.

This means that users on the internet cannot connect directly to SERVER_IP:8000.

Requests will go through Nginx.

This provides a simple, straightforward setup:

This is particularly convenient for a tutorial deployment because the port is easy to test with curl, ss, and other standard tools.

The second option is a Unix socket.

Instead of using TCP, Gunicorn can create a special socket file.

For example: /run/gunicorn/django-app.sock

 Nginx and Gunicorn then communicate through this socket within the same Linux system.

The resulting setup looks like this:

No TCP port is used between the two local processes.

Unix sockets are common in production configurations of this kind, but they add another layer of permission management:

  • Who owns the socket?
  • Can Nginx open it?
  • Does the directory exist?
  • What permissions does it have?

If anything is configured incorrectly, you may get a 502 Bad Gateway instead of the website, even though Django itself is running perfectly well.

For this guide, we will therefore choose the more straightforward option: 127.0.0.1:8000

It is entirely sufficient for our setup.

 Gunicorn is not exposed externally, and Nginx will later be able to connect to it using proxy_pass http://127.0.0.1:8000;

It is useful to remember this simple rule:

  • 127.0.0.1:8000 → accessible only from the VPS itself
  • 0.0.0.0:8000 → listens on all IPv4 interfaces

Therefore, there is no need to use –bind 0.0.0.0:8000 in a production setup behind Nginx unless necessary.

We have now confirmed that the application works through a production WSGI server.

However, it is still started manually: if we close the SSH session or reboot the VPS, Gunicorn will stop.

Next, we will hand process management over to systemd so that the application starts automatically and runs as a standard system service.

Running Gunicorn as a System Service

Why Gunicorn Needs systemd

systemd is a service manager for Linux.

It already manages many components on our server.

For example:

  • PostgreSQL
  • Nginx
  • SSH

Managing Gunicorn manually every time would be impractical, so we will configure it as a standard system service.

This will allow systemd to:

  • Start Gunicorn when the VPS boots;
  • Stop and restart the service;
  • Display its status;
  • Store logs;
  • Automatically restart the process after a failure if necessary.

In other words, instead of the command:

gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application

we will later use:

sudo systemctl start gunicorn

sudo systemctl restart gunicorn

sudo systemctl status gunicorn

This is much more convenient from an operational perspective.

Create gunicorn.service

Custom systemd service files are typically placed in: /etc/systemd/system/

Let’s create a new unit: sudo nano /etc/systemd/system/gunicorn.service

Add the following:

[Unit]

Description=Gunicorn for Django application

After=network.target postgresql.service

[Service]

User=ubuntu

Group=www-data

WorkingDirectory=/var/www/django-app

ExecStart=/var/www/django-app/venv/bin/gunicorn \

–workers 3 \

–bind 127.0.0.1:8000 \

myproject.wsgi:application

Restart=on-failure

[Install]

WantedBy=multi-user.target

There are several new parameters here, so let’s examine them in more detail.

Specifying the Working Directory and Environment

Let’s start with the following section:

[Unit]

Description=Gunicorn for Django application

After=network.target postgresql.service

Description is simply a clear description of the service.

After=network.target postgresql.service tells systemd to start Gunicorn after the basic network services and PostgreSQL have started.

This does not mean that systemd will magically check whether the database itself is operational, but it does make the startup order more logical.

Now for the [Service] section.

This section contains the main process configuration.

User

User=ubuntu

Gunicorn will run as a regular user rather than as root.

This is the better approach from a security perspective.

If the application is compromised, the process should not automatically gain full administrative access to the entire server.

Of course, if your user has a different name, specify the actual username. Ideally, you should create a dedicated user for this purpose.

You can check it as follows: whoami

Group

Group=www-data

www-data is commonly used by Nginx and other web services on Ubuntu.

In our configuration, Nginx will connect to Gunicorn through a local TCP port, so shared access to a Unix socket is not required.

Nevertheless, this group remains a standard choice for a web application.

WorkingDirectory

WorkingDirectory=/var/www/django-app

This specifies the process’s working directory.

From here, Gunicorn will be able to correctly import myproject.wsgi and locate the project files.

If WorkingDirectory is omitted, systemd may run the command from a different directory, resulting in errors such as: ModuleNotFoundError: No module named ‘myproject’

Even though the project itself is present on the server and runs perfectly when started manually.

ExecStart

The most important line:

ExecStart=/var/www/django-app/venv/bin/gunicorn \

–workers 3 \

–bind 127.0.0.1:8000 \

myproject.wsgi:application

Note: here we do not simply write: gunicorn

We specify the full path: /var/www/django-app/venv/bin/gunicorn

This ensures that systemd runs Gunicorn from our project’s virtual environment.

Otherwise, you may encounter the following problem:

Via SSH

→ Gunicorn from the virtual environment is used

→ the application works

Via systemd

→ a different Python/Gunicorn installation is used

→ dependencies are not found

Now: –workers 3 sets the number of worker processes.

A worker is a separate Gunicorn process that can handle requests.

Schematically:

The three workers shown here are simply a straightforward starting example, not a universal value for every VPS.

The optimal number depends on:

  • The number of CPUs;
  • The nature of the workload;
  • The amount of memory;
  • Whether the application is CPU-bound or I/O-bound.

Therefore, 3 should not be treated as a magic production constant.

Next: –bind 127.0.0.1:8000 keeps Gunicorn accessible only from within the VPS.

A: myproject.wsgi:application points to the Django WSGI application.

If the project has a different name, this part must be replaced.

What about .env?

There is an important point to note here.

In the previous chapter, we used python-dotenv and loaded the file: load_dotenv(BASE_DIR / “.env”) directly in settings.py.

Therefore, when launched via Gunicorn, Django will read the following file automatically: /var/www/django-app/.env

But only if:

  • The file exists;
  • BASE_DIR is defined correctly;
  • The ubuntu user that runs Gunicorn has permission to read it.

Let’s check the permissions: ls -l /var/www/django-app/.env

We previously set: chmod 600 .env

If it is still owned by the same ubuntu user, everything is fine.

This gives us the following chain:

In other words, we do not need to specify the PostgreSQL password directly in gunicorn.service.

This is a good thing: secrets are not spread across multiple configuration files.

Starting the service and enabling it at boot

Save the unit file.

After creating or modifying the systemd configuration, reload the unit files: sudo systemctl daemon-reload

Now start Gunicorn: sudo systemctl start gunicorn

Check: sudo systemctl status gunicorn --no-pager

If everything is working correctly, we will see the following status: Active: active (running)

Now enable automatic startup: sudo systemctl enable gunicorn

You can combine starting and enabling the service in a single command: sudo systemctl enable --now gunicorn

The difference is straightforward:

  • start → starts the service immediately
  • enable → starts the service automatically at system boot
  • enable –now → performs both actions

Now, even after the VPS reboots, systemd can start Gunicorn again.

Check whether automatic startup is enabled: sudo systemctl is-enabled gunicorn

Expected: enabled

Checking the Status and Logs

The first diagnostic command to run: sudo systemctl status gunicorn --no-pager

However, the status provides only part of the information.

If Gunicorn fails to start, check the log: sudo journalctl -u gunicorn

Latest entries: sudo journalctl -u gunicorn -n 50 --no-pager

To monitor the log in real time: sudo journalctl -u gunicorn -f

This is especially useful when starting the application and troubleshooting import errors, PostgreSQL connection issues, or Django configuration problems.

For example, if there is an error in myproject.wsgi:application the log may show: ModuleNotFoundError

If Gunicorn cannot read the .env file, Django may report a missing SECRET_KEY or missing database settings.

If the port is already in use: 127.0.0.1:8000, we will see a bind error.

You can check the port separately: sudo ss -lntp | grep 8000

When Gunicorn is running, we expect it to be listening on 127.0.0.1:8000.

And one final practical check: curl http://127.0.0.1:8000

If the application responds, it is now running as a full-fledged system service rather than from our terminal.

You can even close the SSH session, reconnect, and run the command again: sudo systemctl status gunicorn --no-pager

Gunicorn will remain in place.

Next, we will add Nginx as another layer—it will serve as the public entry point to our Django application.

Putting Nginx in Front of Django

Why You Should Not Expose Gunicorn Directly to the Internet

Technically, you could run Gunicorn as follows: gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application and open port 8000 in the external firewall.

The website would even be accessible at an address like: http://203.0.113.10:8000

However, this is not a good approach for a production environment.

Gunicorn is primarily designed to run Python applications. Handling external HTTP traffic is better left to a dedicated web server.

Nginx is better suited to tasks such as:

  • Accepting requests on ports 80 and 443;
  • Managing domains;
  • Handling TLS and HTTPS;
  • Serving static and media files;
  • Proxying requests;
  • Processing HTTP headers;
  • Enforcing request size limits;
  • Keeping access and error logs.

Therefore, we keep Gunicorn internal to the VPS: 127.0.0.1:8000, and the user does not need to know that this port even exists.

This also simplifies the security architecture: only services that genuinely need to be public are exposed externally.

Creating the Site Configuration

Install Nginx:

sudo apt update

sudo apt install -y nginx

Let’s check the service: sudo systemctl status nginx --no-pager

If everything is working correctly, we will see: Active: active (running)

We’ll also enable automatic startup in case it hasn’t already been enabled for some reason: sudo systemctl enable nginx

Now let’s create a separate configuration for the Django project: sudo nano /etc/nginx/sites-available/django-app

To start with, the configuration can look like this:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

location / {

proxy_pass http://127.0.0.1:8000;

}

}

For now, it contains just a few lines:

  • listen 80 tells Nginx to accept regular HTTP requests.
  • server_name example.com www.example.com; specifies the domain this server block is intended for.

We will connect the actual domain a little later, so for now we are using the documentation example: example.com

And inside it:

location / {

…

}

all regular requests to the site are handled.

Configuring Proxying to Gunicorn

The key line is: proxy_pass http://127.0.0.1:8000;

This tells Nginx to forward any request that matches this location to the application running locally on port 8000.

This is the same address that we specified in gunicorn.service: –bind 127.0.0.1:8000

Therefore, both sides must match.

If Gunicorn is listening on: 127.0.0.1:8000

and accidentally configure Nginx with: proxy_pass http://127.0.0.1:9000;

 Nginx will not find the application and will most likely return: 502 Bad Gateway

Before enabling the configuration, it is a good idea to check Gunicorn again: sudo ss -lntp | grep 8000 and curl http://127.0.0.1:8000

If Django responds locally, the backend is ready for Nginx.

Passing the required HTTP headers

The proxy_pass directive is sufficient for basic proxying, but it is also useful to provide Django with information about the original request.

Let’s extend the configuration:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

location / {

proxy_pass http://127.0.0.1:8000;

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

}

}

Let’s examine these lines:

DirectiveValue passedWhy it is needed
proxy_set_header Host $host;The original request hostDjango uses it when validating ALLOWED_HOSTS, among other things.
proxy_set_header X-Real-IP $remote_addr;The client’s IP addressAllows the application and its logs to record the user’s address rather than only Nginx’s local address.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;The proxy chain and the client’s original IP addressUseful for logging, analytics, and requests that pass through multiple proxy servers.
proxy_set_header X-Forwarded-Proto $scheme;The original request scheme: http or httpsThis is especially important after enabling TLS: the application behind the reverse proxy must recognize that the user connected over HTTPS.

Without the correct proxy header, an application behind a reverse proxy may treat the request as HTTP even though HTTPS is used externally.

Enable the configuration and check it with nginx -t

A file in sites-available is not active by itself.

Create a symbolic link:

 sudo ln -s /etc/nginx/sites-available/django-app /etc/nginx/sites-enabled/

The configuration now appears among the enabled sites.

Before reloading Nginx, be sure to check the configuration syntax: sudo nginx -t

If the configuration is valid, the output should look something like this:

syntax is ok

test is successful

Only then do we apply the changes: sudo systemctl reload nginx

 Use reload specifically; a full restart is not necessarily required.

On reload, Nginx rereads its configuration without fully stopping the service.

If the default configuration is still enabled on the VPS: /etc/nginx/sites-enabled/default, it may sometimes interfere with IP-based checks or intercept requests that do not match our server_name.

If necessary, you can disable the default site: sudo rm /etc/nginx/sites-enabled/default

Then run the following commands again:

sudo nginx -t

sudo systemctl reload nginx

Now test Nginx locally.

For example: curl -H "Host: example.com" http://127.0.0.1

The option: -H “Host:example.com” allows you to test the required server_name before the actual DNS is fully configured.

If the Django page appears, Nginx is successfully accepting the request and forwarding it to Gunicorn.

Public HTTP traffic can now pass through Nginx, while Gunicorn remains hidden on a local port.

The next step is to configure static and media files. We will not route them through Django and Gunicorn unnecessarily; Nginx can serve these files directly.

Serving Static and Media Files with Nginx

The difference between static and media

At first glance, both contain ordinary files.

However, they serve different purposes.

Static refers to the application’s own files:

  • CSS;
  • JavaScript;
  • icons;
  • UI images;
  • fonts;
  • Django Admin static files.

They are typically bundled with the project code.

For example:

/static/css/style.css

/static/js/app.js

/static/admin/css/base.css

And media are files that are created while the application is running.

For example:

  • User avatars;
  • Product photos;
  • Uploaded documents;
  • Attachments;
  • Images uploaded through forms.

In other words:

static

→ part of the application

media

→ user-provided or generated content

This distinction is also important during deployment.

Static files can be regenerated from the project using collectstatic.

Media files cannot be recovered so easily: they contain actual user data and require a separate backup.

Why Gunicorn Should Not Serve Them

Technically, Django can serve files itself.

This is often exactly what happens during development.

But in production, there is little point in having Gunicorn and Django handle every CSS file or image.

Consider the following request: GET /static/css/style.css

If it is routed to Django, the application has to accept the request, process it in Python, and only then return the file.

Nginx, however, can simply read the required file from disk and send it directly to the client.

This is a natural task for a web server.

Therefore, we separate responsibilities:

  • Dynamic URLs are routed to Gunicorn;
  • /static/ is served by Nginx;
  • /media/ is also served by Nginx.

This keeps Python processes from being tied up with work that is much easier for the web server to handle.

Configuring STATIC_ROOT and MEDIA_ROOT

We briefly covered STATIC_ROOT before running collectstatic.

Open: myproject/settings.py

and make sure the settings look something like this:

STATIC_URL = “/static/”

STATIC_ROOT = BASE_DIR / “staticfiles”

MEDIA_URL = “/media/”

MEDIA_ROOT = BASE_DIR / “media”

It is important not to confuse a URL with a file system path.

For example: STATIC_URL = “/static/” specifies the URL that the browser will see: https://example.com/static/css/style.css

A: STATIC_ROOT = BASE_DIR / “staticfiles” refers to the actual directory on the server: /var/www/django-app/staticfiles/

The same applies to media files.

MEDIA_URL = “/media/” produces URL: https://example.com/media/avatar.jpg

while: MEDIA_ROOT = BASE_DIR / “media” points to: /var/www/django-app/media/

After changing the static file settings, you can run the following again:

cd /var/www/django-app

source venv/bin/activate

python manage.py collectstatic --noinput

Check the directories:

ls -lah /var/www/django-app/staticfiles

ls -lah /var/www/django-app/media

It is normal if the media directory is currently empty or has not been created yet.

Let’s create it: mkdir -p /var/www/django-app/media

You can now explicitly tell Nginx where to retrieve both types of files.

Adding the /static/ location

Open the site configuration: sudo nano /etc/nginx/sites-available/django-app

Add a separate location block:

location /static/ {

alias /var/www/django-app/staticfiles/;

}

The following is used here: alias

Nginx takes the portion of the URL after /static/ and looks for the corresponding file in the specified directory.

For example, the request: /static/css/style.css will map to the file: /var/www/django-app/staticfiles/css/style.css

Note the trailing /: alias /var/www/django-app/staticfiles/;

In configurations like this, pay close attention to slashes rather than adding them arbitrarily: the combination of location and alias directly affects the resulting file path.

Add a location /media/ block

The configuration for user-uploaded files is nearly identical:

location /media/ {

alias /var/www/django-app/media/;

}

The complete configuration can now look something like this:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

location /static/ {

alias /var/www/django-app/staticfiles/;

}

location /media/ {

alias /var/www/django-app/media/;

}

location / {

proxy_pass http://127.0.0.1:8000;

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

}

}

Requests are now routed at the Nginx level.

Nginx serves static and media files directly and forwards everything else to the application.

There is one important caveat.

If media contains private filesthat should be accessible only to authenticated users, you must not simply expose the entire directory through a standard location /media/ block.

This configuration is suitable for public uploads, such as avatars or product images.

Private documents require a separate access control mechanism.

Checking Files in a Browser

Before applying the changes, let’s check the Nginx configuration: sudo nginx -t

If you see:

syntax is ok

test is successful

reload the configuration: sudo systemctl reload nginx

Now locate any existing static file.

For example, after running collectstatic, the Django Admin files will almost certainly be present: find /var/www/django-app/staticfiles -type f | head

You may see something like: /var/www/django-app/staticfiles/admin/css/base.css

Then check: http://example.com/static/admin/css/base.css

Alternatively, use curl:

curl -I -H "Host: example.com" \

http://127.0.0.1/static/admin/css/base.css

We are interested in the successful HTTP status code: HTTP/1.1 200 OK

For media, you can temporarily create a test file: echo “media works” > /var/www/django-app/media/test.txt

And request:

curl -H "Host: example.com" \

http://127.0.0.1/media/test.txt

If we get: media works, then Nginx is accessing the directory correctly.

After verification, the file can be deleted: rm /var/www/django-app/media/test.txt

If we get the following instead: 403 Forbidden or 404 Not Found there are two things to check.

First, check that the path in the alias directive is correct.

Second, check the permissions on the directories themselves:

namei -l /var/www/django-app/staticfiles

namei -l /var/www/django-app/media

 Nginx must be able to traverse the parent directories and read the required files.
 Nginx now handles not only dynamic requests to Django, but also the application's files.

The remaining step is to make the site usable for real users, rather than just the server itself: configure a domain name, then replace plain HTTP with HTTPS.

Connecting a Domain

Creating a DNS A Record

Let’s start with DNS.

With your domain registrar or DNS provider, create an A recordthat points the domain to the VPS’s public IPv4 address.

For example:

TypeNameValue
A@203.0.113.10
Awww203.0.113.10

Here:

  • @ denotes the root domain;
  • www denotes the www.example.com subdomain;
  • 203.0.113.10 is an example VPS IP address.

After you save the changes, DNS updates may not take effect immediately.

To check where the domain currently points, use: dig +shortexample.com or: nslookup example.com

The result should show our server’s IP address: 203.0.113.10

Let’s check the same for www: dig +short www.example.com

If the old IP address is still returned, there is no point in configuring Nginx yet: requests simply are not reaching the correct VPS.

Propagation time depends in part on the record’s TTL and DNS caches, so you may need to wait a while.

Configuring server_name and ALLOWED_HOSTS

Once the DNS records point to our VPS, we need to configure two separate layers.

The first is Nginx.

Open: sudo nano /etc/nginx/sites-available/django-app

Specify the project’s domain in server_name. In our example: server_name example.com www.example.com; 

This tells Nginx which server block to use for requests to these hostnames.

Next is Django.

Previously, .env contained: ALLOWED_HOSTS=example.com,www.example.com

For your project, specify the same domain names here that are used in server_name. In our example, leave the following: ALLOWED_HOSTS=example.com,www.example.com 

Django reads this string in settings.py and converts it into a list of allowed hostnames.

It is useful to understand the distinction:

SettingUsed byPurpose
server_nameNginxSelects the configuration for a specific domain
ALLOWED_HOSTSDjangoAllows Django to handle requests with this Host header

In other words, server_name alone is not enough.

Nginx may correctly forward the request to Gunicorn, but Django will then respond with: DisallowedHost, if the domain is not in ALLOWED_HOSTS.

After modifying .env, restart Gunicorn: sudo systemctl restart gunicorn

And after editing the Nginx configuration: sudo nginx -t

If the configuration is valid:

syntax is ok

test is successful

apply the changes: sudo systemctl reload nginx

Testing the Website over HTTP

You can now test the entire public request chain.

Open in a browser: http://example.com

Separately: http://www.example.com

Alternatively, use curl: curl -I http://example.com

If the application is running, we will receive the expected HTTP response, for example: HTTP/1.1 200 OK

You can also check the details: curl -v http://example.com

If the domain loads the Django application, several layers are already configured correctly:

  • DNS points to the correct VPS;
  • Port 80 is accessible;
  • Nginx accepts the request;
  • The server_name matches;
  • Gunicorn is running;
  • Django accepts the domain via ALLOWED_HOSTS.

Of course, we will not leave the website running over plain HTTP.

Enabling HTTPS with Certbot.

With standard HTTP, data is transmitted between the browser and the server without TLS encryption. This is particularly important for login forms, cookies, the admin panel, and any user data.

After HTTPS is enabled, the URL will look like this:

https://example.com

We will use Certbot and Let’s Encrypt to obtain a free TLS certificate.

Installing Certbot and Obtaining a Certificate

Install Certbot along with the Nginx plugin:

sudo apt update

sudo apt install -y certbot python3-certbot-nginx

Let’s check: certbot --version

Now let’s request a certificate: sudo certbot --nginx -d example.com -d www.example.com

Here: -dexample.com and –dwww.example.com specify the domain names to be included in the certificate.

Certbot will ask you to provide an email address and accept the terms of service, then attempt to verify ownership of the domain.

Therefore, by this point, DNS must already point to our VPS, and Nginx must respond correctly over HTTP.

If the certificate is issued successfully, Certbot will confirm that it has been obtained and store it in a directory such as /etc/letsencrypt/live/example.com/

This directory contains, among other files:

fullchain.pem

privkey.pem

You do not need to manually copy these files to the Django directory.

Certbot can automatically configure Nginx to use them.

After that, verify the configuration: sudo nginx -t and sudo systemctl reload nginx

The website should now be accessible at: https://example.com

You can also check from the terminal: curl -I https://example.com

Configuring a Redirect to HTTPS

After enabling TLS, it is best not to maintain two separate versions of the website: http://example.com and https://example.com

We will redirect regular HTTP requests to the secure HTTPS version.

 Certbot with the Nginx plugin can usually add this redirect automatically.

The resulting configuration will contain logic similar to the following:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

return 301 https://$host$request_uri;

}

Now the request: http://example.com/admin/

redirects to: https://example.com/admin/

Let’s check: curl -I http://example.com

Expected redirect status code: HTTP/1.1 301 Moved Permanently and header: Location: https://example.com/

There is one more important setting to configure in Django.

Because TLS terminates at Nginx, Gunicorn receives a locally proxied request. Django therefore needs to interpret the forwarded X-Forwarded-Proto header correctly.

In settings.py, specify:

SECURE_PROXY_SSL_HEADER = (

“HTTP_X_FORWARDED_PROTO”,

“https”,

)

We previously added the following directive to Nginx: proxy_set_header X-Forwarded-Proto $scheme;

This setting is particularly useful if you later use Django features that depend on HTTPS.

For production, you should also enable the secure cookie flags:

SESSION_COOKIE_SECURE = True

CSRF_COOKIE_SECURE = True

If the application accepts POST requests through forms or the admin interface, specify the trusted HTTPS origins:

CSRF_TRUSTED_ORIGINS = [

“https://example.com”,

“https://www.example.com”,

]

After changing the Django settings: sudo systemctl restart gunicorn

Testing automatic renewal

Let’s Encrypt certificates have a limited validity period, so manually reissuing them every few months would hardly be convenient.

Certbot sets up automatic renewal.

Let’s check the corresponding timer: systemctl status certbot.timer --no-pager

You can also view the schedule: systemctl list-timers | grep certbot

But the key test is a simulated renewal: sudo certbot renew --dry-run

The –dry-run option lets you test the process without actually reissuing the production certificate.

If everything is configured correctly, Certbot will complete the test successfully.

You can also view the certificates that are already installed: sudo certbot certificates

The domains and the certificate’s validity period will be displayed there.

After that, we perform a final check of the site using curl -Ihttps://example.com and plain HTTP: curl -I http://example.com

The first should work normally over HTTPS, while the second should redirect to it.

All that remains is the less enjoyable but highly useful part: figuring out where to look if one of the components in this setup suddenly stops working.

If the Application Doesn’t Work: Troubleshooting Common Issues

502 Bad Gateway: Checking Nginx and Gunicorn

A 502 Bad Gateway error usually means that Nginx itself is running but cannot get a valid response from the backend.

In our configuration, the backend is Gunicorn at 127.0.0.1:8000.

Let’s start with Gunicorn.

Check the service: sudo systemctl status gunicorn --no-pager

If Gunicorn is stopped: sudo systemctl start gunicorn or: sudo systemctl restart gunicorn

After that, check the port: sudo ss -lntp | grep 8000

If Gunicorn is listening on the expected address, we will see 127.0.0.1:8000.

Now for the most useful test: curl http://127.0.0.1:8000

If Django responds directly but requests through Nginx still result in a 502 error, the issue lies closer to the proxy configuration.

Check: proxy_pass http://127.0.0.1:8000;

The address and port must match those specified in gunicorn.service.

For example, if Gunicorn is listening on port 8000 while Nginx forwards requests to port 8080, the configuration syntax may be perfectly valid, but the application will not work.

That is why: sudo nginx -t

checks the Nginx configuration for syntax errors but does not guarantee that the specified backend actually exists.

Django Cannot Connect to PostgreSQL

If Gunicorn starts but the application crashes with a database error, return to troubleshooting PostgreSQL.

First, check the server itself: sudo -u postgres pg_isready

Then: sudo systemctl status postgresql --no-pager

If PostgreSQL is running, try connecting as the same user specified in .env: psql -h 127.0.0.1 -U django_user -d django_db

If this connection attempt fails, the problem is no longer with Django.

Check the following:

DB_NAME=django_db

DB_USER=django_user

DB_PASSWORD=…

DB_HOST=127.0.0.1

DB_PORT=5432

The most common issues are:

  • Incorrect password;
  • Incorrect database name;
  • A different user;
  • Insufficient permissions;
  • PostgreSQL is not running.

If the connection works through psql but Django still reports an error, verify that the application is actually reading the .env file and that Gunicorn is using the correct virtual environment.

Static or media files return 404 errors

If the application itself loads but the CSS is missing, Django Admin looks like a throwback to 2003, or user-uploaded images return 404 errors, the issue is most likely no longer with Gunicorn.

For static files, first check whether the following command was run: python manage.py collectstatic --noinput

and whether the files exist: ls -lah /var/www/django-app/staticfiles

Next, check the Nginx configuration:

location /static/ {

alias /var/www/django-app/staticfiles/;

}

For media files:

location /media/ {

alias /var/www/django-app/media/;

}

Check the actual file: find /var/www/django-app/staticfiles -type f | head

and request it directly: curl -I http://example.com/static/admin/css/base.css

If the file exists on disk but Nginx does not serve it, check the path in alias and the directory permissions: namei -l /var/www/django-app/staticfiles

The same logic applies to media files.

Another important point to remember is that collectstatic only works with static files.

If a user-uploaded file is not present in MEDIA_ROOT, no Nginx configuration can create it.

DisallowedHost and Domain Errors

An error such as:

DisallowedHost

Invalid HTTP_HOST header

means that the request reached Django, but the application does not accept the specified domain name.

Checking .env: ALLOWED_HOSTS=example.com,www.example.com

and the corresponding setting:

ALLOWED_HOSTS = [

host.strip()

for host in os.getenv(“ALLOWED_HOSTS”, “”).split(“,”)

if host.strip()

]

After modifying .env, restart Gunicorn: sudo systemctl restart gunicorn

We also check Nginx: server_name example.com www.example.com;

And DNS: dig +short example.com

The domain must point specifically to the IP address of the current VPS.

If HTTP works but problems begin only after enabling HTTPS, also check:

CSRF_TRUSTED_ORIGINS = [

“https://example.com”,

“https://www.example.com”,

]

This is particularly relevant for POST requests, forms, and Django Admin.

Where to Check the Logs

When the error itself provides no clear indication of the cause, it is best to check the logs right away.

For Gunicorn: sudo journalctl -u gunicorn -n 50 --no-pager

or in real time: sudo journalctl -u gunicorn -f

For Nginx: sudo tail -f /var/log/nginx/error.log and sudo tail -f /var/log/nginx/access.log

For PostgreSQL, you can start with: sudo journalctl -u postgresql -n 50 --no-pager

If the error occurs within Django, it will often appear in the Gunicorn log because Gunicorn is the process that runs the application.

This gives us a useful rule of thumb:

SymptomWhere to Check First
502 Bad GatewayGunicorn and proxy_pass
Database connection errorPostgreSQL and .env
Static/media files return 404 errorsSTATIC_ROOT, MEDIA_ROOT, alias
DisallowedHostALLOWED_HOSTS, server_name, DNS
Unclear internal errorjournalctl and Nginx logs

In most cases, the problem lies just beyond the last layer that is working correctly.

If Nginx responds but the backend is unavailable, check Gunicorn. If Gunicorn is running but Django fails while processing a request, check the application settings and PostgreSQL. If the HTML loads but the CSS does not, leave the database alone and check the static files.

This approach makes troubleshooting considerably faster than trying to restart everything on the server at once.

Conclusion

Our Django application has now progressed from a basic project on an empty VPS to a full production deployment.

We configured Ubuntu, created a dedicated Python environment, connected PostgreSQL, and moved sensitive settings out of settings.py. We then prepared the migrations and static files, replaced the development server with Gunicorn, and delegated process management to systemd.

Nginx, in turn, became the external entry point: it proxies dynamic requests to Gunicorn, serves static and media files directly, handles the domain, and accepts HTTPS traffic.

As a result, each component handles its own task, and the application no longer depends on an open SSH terminal or manually running python manage.py runserver.

However, production deployment does not end here. As the project grows, this architecture can be extended with backups of PostgreSQL and media files, Redis, Celery, monitoring, centralized logging, CI/CD, or multiple application instances behind a load balancer.

But the foundation is already in place. More importantly, it is now clear not only which commands to run, but also why each element was needed in this setup in the first place.

FAQ

Can Django be deployed without Nginx by exposing Gunicorn directly?

Technically, yes. You can bind Gunicorn to a public interface and open the corresponding port.

For a typical production deployment, it is more convenient to keep Gunicorn bound to 127.0.0.1 and place Nginx in front of it. The web server then handles HTTPS, the domain, static files, media files, and other external HTTP traffic.

Do I need to restart Gunicorn after changing the code?

Yes. Running worker processes may not automatically pick up changes to production code.

After updating the project, the following command is usually run: sudo systemctl restart gunicorn

If the update includes changes to models or static files, you may also need to run:

python manage.py migrate

python manage.py collectstatic --noinput

Sequences like these are often automated later through CI/CD.

Can I keep using SQLite for a small website?

Yes.

SQLite does not become unsuitable simply because the project has been deployed on a VPS. Its capabilities may be sufficient for a small internal service, a prototype, or an application with a very light workload.

PostgreSQL becomes particularly useful when you need to handle concurrent queries, more complex data operations, multiple application processes, and future scalability requirements.

Why did the site suddenly stop loading after setting DEBUG=False?

One of the most common causes is ALLOWED_HOSTS.

When debug mode is disabled, Django requires a valid list of allowed hostnames. Therefore, the production domain must be added, for example, through the variable we use:

ALLOWED_HOSTS=example.com,www.example.com

After changing the environment, remember to restart Gunicorn.

Why Does Django Admin Load Without Styles?

This usually means that the issue is not with the admin interface itself, but with static files.

Check: python manage.py collectstatic --noinput for files in STATIC_ROOT and a corresponding location /static/ block in Nginx.

Django Admin uses its own CSS and JavaScript, so issues with serving static files are particularly noticeable there.

Do you need to back up the staticfiles directory?

As a rule, staticfiles can be regenerated using the following command: python manage.py collectstatic

It is therefore much more important to back up anything that cannot be restored from the source code: PostgreSQL data, user-uploaded media, required secrets, and other application-specific information.

What happens if a Let’s Encrypt certificate expires?

If Certbot is configured correctly, the certificate should renew automatically before it expires.

That is why, after configuring HTTPS, it is a good idea to test the renewal process in advance: sudo certbot renew --dry-run

If the test succeeds, you do not need to manually renew the certificate each time.

What should you check after each Django application update?

The minimum set of checks depends on the update itself, but it is a good idea to verify that Gunicorn is running, migrations have been applied, static files are up to date, and the application is responding through Nginx.

After more substantial changes, you should also review the logs and test key user flows. Simply opening the site is not enough if, for example, the login form or writes to PostgreSQL have stopped working.

Sources

  1. Django Documentation — Deployment checklist
  2. PostgreSQL Documentation — CREATE ROLE
  3. NGINX Documentation — ngx_http_proxy_module
  4. Certbot Documentation — Nginx instructions

Subscribe to our newsletter and receive articles and news

    Check out our other materials