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:
Component | Responsibility |
Django | Application logic |
PostgreSQL | Persistent data |
Gunicorn | Running Django as a production web application |
systemd | Managing and automatically starting the Gunicorn process |
Nginx | External 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:
Package | Purpose |
| python3 | The Python interpreter |
| python3-pip | Installing Python packages |
| python3-venv | Creating virtual environments |
| python3-dev | Python header files needed to build certain dependencies |
| build-essential | Compiler and essential build tools |
| libpq-dev | PostgreSQL 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:
Directive | Value passed | Why it is needed |
| proxy_set_header Host $host; | The original request host | Django uses it when validating ALLOWED_HOSTS, among other things. |
| proxy_set_header X-Real-IP $remote_addr; | The client’s IP address | Allows 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 address | Useful for logging, analytics, and requests that pass through multiple proxy servers. |
| proxy_set_header X-Forwarded-Proto $scheme; | The original request scheme: http or https | This 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:
Type | Name | Value |
A | @ | 203.0.113.10 |
A | www | 203.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:
Setting | Used by | Purpose |
| server_name | Nginx | Selects the configuration for a specific domain |
| ALLOWED_HOSTS | Django | Allows 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:
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:
Symptom | Where to Check First |
| 502 Bad Gateway | Gunicorn and proxy_pass |
| Database connection error | PostgreSQL and .env |
| Static/media files return 404 errors | STATIC_ROOT, MEDIA_ROOT, alias |
DisallowedHost | ALLOWED_HOSTS, server_name, DNS |
| Unclear internal error | journalctl 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.
