Deploying a Django App on Ubuntu VPS with Nginx and Uvicorn
A step-by-step guide to production deployment
Aug. 26, 2025Prerequisites
This article assumes that you are already in possession of a VPS or any web server with Ubuntu 24.04 LTS, a ready-to-deploy Django project, and last but not least, a domain name. This article doesn't focus on the entire Django configuration for production, but only on what is required to have it running.
Install Python 3.13
If the server doesn't have Python 3.13 installed by default, you can still install another version in parallel:
sudo add-apt-repository ppa:deadsnakes/ppa sudo apt update sudo apt install python3.13
Now version 3.13 can be recalled directly, e.g., by opening IDLE via:
python3.13
Build the application
Now that the required Python version is installed, we can proceed to build the application, which means creating a compressed archive containing all Python files, requirements.txt, and the static content:
tar -czvf build.tar.gz manage.py requirements.txt [...] # all the required files to run the application
Upload this file to the your server:
scp build.tar.gz <username>@<ipaddress>:~/name/of/your/app
Once the file is loaded enter via SSH into the VPS:
ssh <username>@<ipaddress>
and uncompress the archive in the desired path:
cd /path/to/build.tar.gz tar -xvf build.tar.gz
Prepare virtual environment and install dependencies
Enter the directory with the files obtained from the archive and create a new Python virtual environment with the installed Python version:
cd /path/to/app python3.13 -m venv --without-pip venv
the --without-pip flag can be found in Python docs for further details about it.
Now activate the virtual environment and install pip in it:
source venv/bin/activate curl -sSL https://bootstrap.pypa.io/get-pip.py -o get-pip.py python get-pip.py
pip is now available but must be explicitly invoked with the version.
It’s now time to install the dependencies with the requirements file:
# having the virtualenvironment activated pip3.13 install -r requirements.txt
All further operations regarding Python are going to be made under the virtual environment — this is implicit from now on.
Associate domain to VPS
Before moving forward with the deployment procedure, associate your domain with the VPS by adding two A records:
- @ → Points to the IP address of the VPS hosting the application
- www → Points to the same VPS IP address
Serve the application
It is possible now to run uvicorn, which should be inserted in the requirements.txt file to be then downloaded when instaling dependencies with pip.
Uvicorn can be deployed in different ways, but for the purpose of this article, only one is demonstrated. You might need a different configuration for your web application:
/path/to/your/venv/bin/python -m uvicorn <project-name>.asgi:application --port 8080 --host 0.0.0.0
If the application starts properly, then it's time to move on.
Some tuning is probably required in the <project-name>/settings.py file e.g.:
DEBUG = False SECRET_KEY = 'change with a secure secret key' ALLOWED_HOSTS = ['your.domain', 'www.your.domain', 'localhost', '127.0.0.1'] CSRF_TRUSTED_ORIGINS = ['https://your.domain']
those are just some example — you may need to adjust more parameters, but nothing the django docs cannot help with.
Auto start on VPS boot
There are still some things missing before the application is fully running in your browser. However, a nice-to-have is not needing to start the Uvicorn server manually every time, so we can create a systemd service to handle this.
Create a new file /etc/systemd/system/webapp.service and paste the following content, replacing the necessary parts:
[Unit] Description=Django web application After=network.target [Service] User=root Group=root WorkingDirectory=/path/to/app ExecStart=/path/to/venv/bin/python -m uvicorn <django-project>.asgi:application --port 8080 --host 0.0.0.0 Restart=always RestartSec=3 [Install] WantedBy=multi-user.target
It's then time to make the service available and to start it:
sudo systemctl daemon-reload sudo systemctl enable webapp sudo systemctl start webapp sudo systemctl status webapp
It's now time for NGINX
We could make the application available simply using Uvicorn — no big deal — but a web server like NGINX can be beneficial. For example, it can be used to configure multiple web apps within a single server or, more importantly, to deliver Django static files. Yes, because Django in production needs extra care compared to the development server. If you try to access your static files, a 404 error will be returned, so a web server is required.
I chose NGINX because — why not? I had never used it before, and it was time to learn something more about it, right?
Make sure it’s installed; otherwise, run:
sudo apt udpate sudo apt install nginx
To allow your web server communicate with the outside world it's required to allow traffic from port 80 by doing:
sudo ufw allow 80
and typing now:
sudo ufw status
should display something like:
To Action From -- ------ ---- OpenSSH ALLOW Anywhere 80 ALLOW Anywhere OpenSSH (v6) ALLOW Anywhere (v6) 80 (v6) ALLOW Anywhere (v6)
Visiting now the domain name with http and NOT https in your web browser an NGINX default web page should appear, if so the configuration is correct. But how to serve the content provided by django? What about the static content? We are getting there no worries.
NGINX has two directories that we have to search for now which are:
- /etc/nginx/sites-available containing the available configurations for your web server
- /etc/nginx/sites-enabled containing the active configurations for your web server
The difference between the two is that in te sites-available directory there are files, the second one should be used creating soft links in there of the available sites to enable.
To enable then our web site we can delete the default website configuration:
sudo rm /etc/nginx/sites-enabled/default
And getting some inspiration from the default file we can create a new file /etc/nginx/sites-available/webapp
server { root /var/www/webapp; server_name your.domain www.your.domain; location / { # First attempt to serve request as file, then # as directory, then fall back to displaying a 404. # try_files $uri $uri/ =404; proxy_pass http://localhost:8080; } location /static/ { autoindex on; alias /var/www/webapp/static/; } }
Validate the configuration:
nginx -t
By taking a closer look we can see the alias on /var/www/webapp/static and it's the first we mention it. You're probably thinking that the directory for static files in the direcotry you copied to the VPS is enoug? Well no. Open the settings.py and take and add:
STATIC_ROOT = '/var/www/webapp/static'
and run the run django manage script to collect static content and copy it to the STATIC_ROOT direcory:
python3.13 manage.py collectstatic --noinput
The directory specified in settings.py s now being created. It contains the static files required for the Django admin interface to display properly, as well as the files you added manually.
To enable the website create a soft link into the enabled sited directory and restart nginx:
sudo ln -s /etc/nginx/sites-available/webapp /etc/nginx/sites-enabled/
and restart nginx
sudo service nginx restart
Just like that you should be able to have the application available in your web browser by accessint the domain specifying HTTP instead of HTTPS
It work's! But what about HTTPS?
Almost done, this is the last step and it's easier than expected, to obtain an SSL certificate simply use let's encrypt certbot:
sudo apt install certbot python3-certbot-nginx
Open the 443 port to allow HTTPS and create a new certificate for the doain:
sudo ufw allow 443 sudo certbot --nginx -d your.domain -d www.your.domain
Check the certificate proper creation:
certbot certificates
Reload and restart NGINX:
sudo service nginx reload sudo service nginx restart
Just like that HTTPS is enabled and should be visible in your browser as well!
Manage certificates expiration
Let’s Encrypt certificates have a 90-day expiration period. Manually renewing them on the VPS every three months is inefficient, so we automate the process to avoid unnecessary repetition.
Installing certbot automatically creates two files:
- /usr/lib/systemd/system/certbot.service
- /usr/lib/systemd/system/certbot.timer
Each systemd service is allowed to have a timer file for being recalled like you would do with a cronjob, the main difference between cronjobs and systemd timers will be explained in a future article, stay connected!
The only thing to be adapted is the service file:
[Unit] Description=Certbot Documentation=file:///usr/share/doc/python-certbot-doc/html/index.html Documentation=https://certbot.eff.org/docs [Service] Type=oneshot ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew --nginx PrivateTmp=true
Save the changes and reload the service:
sudo systemctl reload certbot.service sudo systemctl restart certbot.service sudo systemctl daemon-reload
You can view logs for the timer and service using the journalctl command to verify everything's working properly for both service and timer.
At this point, your web application is fully functional and secured with automated SSL certificate renewal. You no longer need to worry about expired certificates or manual updates, which means you can focus entirely on developing features and improving the user experience. By combining Django’s built-in tools with automation and Linux services, you’ve set up a professional-grade deployment process. From here, you can continue scaling your app, monitoring performance, and adding new functionality with the confidence that your foundation is solid.