Fedora

Install Django on Fedora 45 / 44: dnf, venv and Gunicorn

Fedora 45’s own Django package trailed upstream by one patch release when we tested. So sudo dnf install python3-django gave us a build missing four CVE fixes, including a denial of service that unauthenticated requests can reach through headers such as Accept. Fedora’s default Python is also newer than anything Django lists as supported, so we ran Django’s own 19,713-test suite on it to see whether that matters.

Original content from computingforgeeks.com - post 4571

This guide installs Django on Fedora with dnf or in a virtual environment, creates a project and the admin user, and then deploys it behind Gunicorn and Nginx with SELinux left enforcing, which is where most Fedora deployments break. We tested Django 6.1.2 from pip and 6.1.1 from Fedora 45’s repository in October 2026, then repeated the install and the full deployment on Fedora 44.

Choose dnf or a virtual environment

Fedora packages two Django branches, and pip gives you a third route that follows upstream on the day of release. The table shows what each route installed on our Fedora 45 machine with only the stable repositories enabled, the default on an installed system:

RouteDjango we got (October 2026)PythonUpdates arrive
dnf install python3-django6.1.1 (6.1.2 in updates-testing)System, 3.15When Fedora pushes them, feature releases included
dnf install python3-django55.2.17 LTSSystem, 3.15When Fedora pushes them, LTS branch only
venv + pip install django6.1.2Any interpreter you pickWhen you run pip

We’d use a virtual environment for anything you plan to deploy. It pins Django per project, it cannot break dnf or other Python tools on the machine, and it got the security release the day upstream shipped it. The dnf packages suit a quick experiment or a system tool that should follow Fedora’s update stream, and they pull in only three dependencies.

Install Django with dnf

Check which builds your release carries before installing, because the stable and testing repositories often differ by a security release. This query also lists the two libraries Django needs:

dnf repoquery --qf "%{name} %{version}-%{release} %{repoid}\n" python3-django python3-django5 python3-asgiref python3-sqlparse

With updates-testing enabled, our Fedora 45 VM showed the newer patch release waiting there for both branches. Without it, dnf installs the fedora build.

python3-asgiref 3.11.1-3.fc45 fedora
python3-django 6.1.1-2.fc45 fedora
python3-django 6.1.2-1.fc45 updates-testing
python3-django5 5.2.17-2.fc45 fedora
python3-django5 5.2.18-1.fc45 updates-testing
python3-sqlparse 0.5.3-6.fc45 fedora

Upstream’s patch release of October 6, 2026 fixed one low and three moderate CVEs. One of them, CVE-2026-84429, is a quadratic-time parser in parse_header_parameters() that an anonymous client can reach through content negotiation. If you install from dnf, check that the patched build has reached the stable repository before you expose the site, or pull it from testing as shown below. Install the package:

sudo dnf install python3-django

The transaction is small: Django, asgiref, sqlparse and a bash completion file for django-admin, 6 MiB to download.

Package                         Arch   Version         Repository      Size
Installing:
 python3-django                 noarch 0:6.1.1-2.fc45  fedora      22.2 MiB
Installing dependencies:
 python3-asgiref                noarch 0:3.11.1-3.fc45 fedora     169.0 KiB
 python3-sqlparse               noarch 0:0.5.3-6.fc45  fedora     387.3 KiB
Installing weak dependencies:
 python3-django-bash-completion noarch 0:6.1.1-2.fc45  fedora       2.2 KiB

Transaction Summary:
 Installing:         4 packages

Total size of inbound packages is 6 MiB. Need to download 6 MiB.
After this operation, 23 MiB extra will be used (install 23 MiB, remove 0 B).

Confirm the version and where Python imports it from. The path shows the package lives in the system interpreter’s site-packages, so it moves to the next Python whenever Fedora rebuilds it:

django-admin --version
python3 -c "import django; print(django.__version__, django.__file__)"

Both commands report the Fedora build. A second Django in a venv does not change this path, because the venv only takes over once you activate it.

6.1.1
6.1.1 /usr/lib/python3.15/site-packages/django/__init__.py

If the stable build still trails upstream’s security release, take the patched one from updates-testing for this package only. The repository stays disabled for everything else:

sudo dnf upgrade --enablerepo=updates-testing python3-django

On our VM that replaced the stable build with the testing one, and django-admin --version then printed 6.1.2. Later dnf upgrade runs move it forward again once the stable repository catches up.

Switch to the LTS branch with dnf swap

The LTS branch has security support until April 2028, which suits an app you don’t want to touch for a while. You cannot install both packages side by side, because each provides python-django-impl and conflicts with the other. A plain dnf install python3-django5 fails with “conflicts with python-django-impl provided by python3-django5”, so swap them in one transaction:

sudo dnf swap python3-django python3-django5

dnf removes the current branch, installs the LTS one and replaces the completion file, and django-admin --version then reports the LTS release. Run the swap the other way round to go back. The swap installs the stable LTS build, so if it trails the security release too, run the updates-testing upgrade above with python3-django5 as the package name, which took our VM to the patched LTS build.

Install Django in a virtual environment

The Fedora cloud image we tested on doesn’t ship pip, and python3 -m pip answers “No module named pip”. You do not need it system wide. The venv module bundles its own pip into every environment it creates, so create the project directory and an environment inside it:

mkdir ~/mysite && cd ~/mysite
python3 -m venv .venv
source .venv/bin/activate

Your prompt now starts with (.venv), and python and pip point inside the environment. Install Django from PyPI:

pip install django

pip pulls the current release with newer asgiref and sqlparse builds than Fedora packages.

Collecting django
  Downloading django-6.1.2-py3-none-any.whl.metadata (3.9 kB)
Collecting asgiref>=3.9.1 (from django)
  Downloading asgiref-3.12.1-py3-none-any.whl.metadata (9.4 kB)
Collecting sqlparse>=0.5.0 (from django)
  Downloading sqlparse-0.6.0-py3-none-any.whl.metadata (6.0 kB)
Downloading django-6.1.2-py3-none-any.whl (8.4 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 8.4/8.4 MB 6.5 MB/s  0:00:01
Downloading asgiref-3.12.1-py3-none-any.whl (25 kB)
Downloading sqlparse-0.6.0-py3-none-any.whl (50 kB)
Installing collected packages: sqlparse, asgiref, django

Successfully installed asgiref-3.12.1 django-6.1.2 sqlparse-0.6.0

Check it with python -m django --version, which should print the release pip just installed. Prefer that form over a bare django-admin, because a dnf-installed /usr/bin/django-admin answers too once you leave the environment, and on our VM that was the older Fedora build.

Pick the Python interpreter for Django

Django’s installation FAQ lists the Python versions each release supports, and the current branch stops one release short of the default interpreter on Fedora 45. pip installs it anyway, because the package declares only a minimum Python version, so the question is whether anything breaks. We ran Django’s full test suite from the release tag under both interpreters on the same VM:

Django test suite results on Fedora 45 default Python and python3.14, 19713 tests OK

Both runs finished all 19,713 tests with no failures and the same seven expected failures, and the newer interpreter finished about 7% faster. The 2,010 skipped tests are mostly the database backends and optional libraries we didn’t install, so this covers SQLite and the core framework, not PostgreSQL or GIS. Fedora’s own package runs the same suite during its build against the default Python, so a regression there would stop the package from building.

For development, the default python3 is fine. For a server, we’d still build the environment on python3.14, which Fedora 45 ships as a parallel package, because a bug you hit there is a bug Django will accept and fix. Switch to the default interpreter once the FAQ lists it, which Django usually does in a patch release. Fedora 44 users don’t have to choose, because its default interpreter is already one Django supports. On Fedora 45, run deactivate if the environment from above is still active, then install the parallel interpreter and rebuild the environment on it:

sudo dnf install python3.14
python3.14 -m venv --clear .venv
source .venv/bin/activate
pip install django

The --clear flag empties the old environment first, so nothing built for the previous interpreter survives, and Django is reinstalled for the new one. On our VM python -V inside the rebuilt environment reported the parallel interpreter and manage.py check passed. The deployment section below builds its environment the same way, so you can keep the default interpreter on your laptop and still match what runs in production.

Create a project and the admin user

With the environment active in ~/mysite, generate the project in the current directory. The trailing dot keeps manage.py next to .venv instead of nesting a second mysite folder:

django-admin startproject mysite .

That writes manage.py and a mysite/ package holding settings.py, urls.py, wsgi.py and asgi.py. The admin, sessions and auth apps need tables before anything works, so apply the initial migrations to the default SQLite database:

python manage.py migrate

Eighteen migrations ran on a fresh project, ending with the sessions table. They create db.sqlite3 next to manage.py.

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

Create the superuser you’ll log in to the admin with. Django prompts for a username, an email and the password twice, and it rejects passwords that are too short, too common or too close to the username:

python manage.py createsuperuser

The password prompts do not echo anything. A rejected password asks whether to bypass validation, and on anything internet facing you should answer no.

Username (leave blank to use 'josphat'): admin
Email address: [email protected]
Password:
Password (again):
Superuser created successfully.

Run the development server

A new project accepts requests for localhost only. Browse to the server you start below from another machine by name or IP and Django answers 400 Bad Request, with this in the server log:

django.core.exceptions.DisallowedHost: Invalid HTTP_HOST header: 'fedora45.example.lan'. You may need to add 'fedora45.example.lan' to ALLOWED_HOSTS.
[15/Oct/2026 12:01:43] "GET / HTTP/1.1" 400 60501

Open mysite/settings.py and put the name or IP address you’ll type in the browser into ALLOWED_HOSTS. Add localhost and 127.0.0.1 as well, because a non-empty list replaces Django’s implicit localhost allowance and curl on the box would stop working. Any editor will do; on a minimal image vim comes from the vim-enhanced package, and vi or nano work the same:

vim mysite/settings.py

Our line reads like this, with fedora45.example.lan standing in for your machine’s name.

ALLOWED_HOSTS = ["fedora45.example.lan", "localhost", "127.0.0.1"]

Whether the port is reachable depends on your Fedora edition. Workstation’s default firewalld zone accepts TCP and UDP from 1025 to 65535, so port 8000 is already open there. Fedora Server’s zone allows SSH and Cockpit only, so a request from another machine fails until you open the port. Fedora Cloud images ship without firewalld at all, and there your provider’s security group decides. Where firewalld runs, open the port for this session only, without --permanent, since you will not run the dev server for long:

sudo firewall-cmd --add-port=8000/tcp

Start the server on all interfaces. Binding to 0.0.0.0 matters, because the default 127.0.0.1:8000 is unreachable from anywhere but the machine itself:

python manage.py runserver 0.0.0.0:8000

The system check runs first. The server then warns you not to use it in production, and Django’s docs say why: it has not gone through security audits or performance tests.

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
October 15, 2026 - 12:02:05
Django version 6.1.2, using settings 'mysite.settings'
Starting WSGI development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.

WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
For more information on production servers see: https://docs.djangoproject.com/en/6.1/howto/deployment/

Open http://fedora45.example.lan:8000/ in a browser. A project with no URLs of its own shows the rocket page, and only because DEBUG is on:

Django install worked successfully rocket page on Fedora 45 development server

The admin lives at /admin/. Sign in with the superuser you created.

Django administration login page on Fedora

A new project’s admin manages only users and groups, and each model you register in an app’s admin.py adds its own section here.

Django site administration dashboard with groups and users after login

Stop the server with Ctrl+C when you’re done. The firewall rule disappears on the next reload or reboot, because it was never made permanent.

Deploy Django with Gunicorn and Nginx

For a real server, Gunicorn runs the app as a systemd service and Nginx sits in front to serve static files and talk to clients. With SELinux enforcing, as it is on every Fedora install, the stock setup fails in three places:

  • systemd cannot execute Gunicorn from a venv under /srv
  • Nginx cannot connect to Gunicorn on port 8000
  • Nginx cannot read the collected static files

We will set the labels and the boolean as part of the steps, then show what each failure looks like in case you skip one, because the error messages rarely mention SELinux. Install Nginx, the python3.14 interpreter and semanage:

sudo dnf install nginx python3.14 policycoreutils-python-utils

Create the app user and environment

Run the app as its own system account with no login shell, so a hole in your code cannot touch your user’s files. Then copy the project to /srv/mysite and build a fresh environment there with Gunicorn:

sudo useradd --system --home-dir /srv/mysite --shell /usr/sbin/nologin mysite
sudo mkdir -p /srv/mysite && sudo cp -r ~/mysite/manage.py ~/mysite/mysite /srv/mysite/
sudo python3.14 -m venv /srv/mysite/.venv
sudo /srv/mysite/.venv/bin/pip install django gunicorn

Only the code goes across, not your development db.sqlite3 or .venv. The last line should end with the installed versions.

Successfully installed asgiref-3.12.1 django-6.1.2 gunicorn-26.2.0 sqlparse-0.6.0

Set the production settings

Rather than rewrite the generated file, append a block of overrides to the end of it. Later assignments win in Python, so these replace the development values without you having to hunt for each line:

sudo vim /srv/mysite/mysite/settings.py

Add these lines at the bottom. Production uses your public domain, shown here as django.example.com, and the same name goes into the Nginx server_name and the certbot command later, so replace it in all three places. The SMTP user and password are optional and come from the same environment file as the key.

# Production overrides
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = ["django.example.com"]
STATIC_ROOT = BASE_DIR / "staticfiles"

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "smtp.example.com",
            "use_tls": True,
            "username": os.environ.get("DJANGO_SMTP_USER", ""),
            "password": os.environ.get("DJANGO_SMTP_PASSWORD", ""),
            "timeout": 10,
        },
    },
}

The MAILERS block is new with this Django release, and it is the one most readers will not expect. A new project’s settings.py now defines a console email backend, which prints mail to the log instead of sending it. Leave it in place and check --deploy fails with an error, not a warning:

ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.console.EmailBackend).
	HINT: Use a production-ready email backend, such as the SMTP backend, otherwise email will not be sent.

The check only runs under --deploy, so the site itself starts either way. Password resets and error mails would silently go nowhere, though, so point the default mailer at a real SMTP server as above. Once mail flows, also set DEFAULT_FROM_EMAIL and SERVER_EMAIL, whose defaults are localhost addresses most relays reject, and ADMINS, which is empty by default so error mails go to nobody. No relay yet? Keep the placeholder host: a fresh project sends no mail on its own, and the check passes with it. The secret key comes from an environment file that only root can read, so it never sits in the code. Generate a random key into it:

echo "DJANGO_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")" | sudo tee /etc/mysite.env >/dev/null
sudo chmod 600 /etc/mysite.env
sudo chown -R mysite:mysite /srv/mysite

The chown lets the app user write the SQLite database and the static files. Now run the migrations, collect static files and the deployment check as that user. Each command loads the key from the file for that one run; the key shows up in the process list while it runs, so do this on a box you don’t share. If you use SMTP credentials, add them to the same file as DJANGO_SMTP_USER= and DJANGO_SMTP_PASSWORD= lines, with no spaces, quotes or glob characters such as * in the values, because the shell splits and expands this form.

sudo -u mysite env $(sudo cat /etc/mysite.env) /srv/mysite/.venv/bin/python /srv/mysite/manage.py migrate
sudo -u mysite env $(sudo cat /etc/mysite.env) /srv/mysite/.venv/bin/python /srv/mysite/manage.py collectstatic --noinput
sudo -u mysite env $(sudo cat /etc/mysite.env) /srv/mysite/.venv/bin/python /srv/mysite/manage.py check --deploy

collectstatic reported 130 files, all from the admin. The deployment check then left four warnings, every one of them about HTTPS, which the last section of this guide clears. The block below shows the output of both commands:

130 static files copied to '/srv/mysite/staticfiles'.
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.

System check identified 4 issues (0 silenced).

The production database is new and empty, because only the code came across, so the superuser from your development project doesn’t exist here. Create the admin account in it the same way:

sudo -u mysite env $(sudo cat /etc/mysite.env) /srv/mysite/.venv/bin/python /srv/mysite/manage.py createsuperuser --username admin --email [email protected]

The flags fill in the name and email, so it asks only for the password:

Password: 
Password (again): 
Superuser created successfully.

Label the venv and static files for SELinux

Everything you create under /srv gets the generic var_t type, and SELinux lets neither systemd nor Nginx use var_t files the way this setup needs. Give the venv’s bin directory the type of normal executables and the static files the type Nginx serves, then apply both rules:

sudo semanage fcontext -a -t bin_t "/srv/mysite/.venv/bin(/.*)?"
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/mysite/staticfiles(/.*)?"
sudo restorecon -R /srv/mysite

Check the result with ls -Zd /srv/mysite/.venv/bin/gunicorn /srv/mysite/staticfiles, which should show bin_t and httpd_sys_content_t. The rules live in the policy store, so a later restorecon or a full relabel keeps them, unlike a one-off chcon.

We skipped the first rule the first time, and the service went into a restart loop. systemd reports a misleading “Unable to locate executable” for a file that is plainly there. The audit log has the real reason, init_t denied execute on a var_t file.

Gunicorn systemd service status 203/EXEC Permission denied and SELinux AVC execute denial on var_t

Setting SELinux to permissive made the service start, which confirms the cause, but leave it enforcing and fix the label. A venv under your home directory fails the same way: we pointed a systemd unit at one and got the same 203/EXEC, with user_home_t in the denial instead of var_t.

Run Gunicorn as a systemd service

Create the unit file. It runs three workers as the mysite user, reads the secret from the environment file and listens on loopback only.

sudo vim /etc/systemd/system/mysite.service

Paste the unit below. Restart=on-failure brings Gunicorn back after a crash, and the loopback bind keeps it unreachable from the network.

[Unit]
Description=mysite Django app (Gunicorn)
After=network.target

[Service]
User=mysite
Group=mysite
WorkingDirectory=/srv/mysite
EnvironmentFile=/etc/mysite.env
ExecStart=/srv/mysite/.venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 mysite.wsgi:application
Restart=on-failure

[Install]
WantedBy=multi-user.target

Reload systemd so it reads the new unit, then start the service now and at every boot.

sudo systemctl daemon-reload
sudo systemctl enable --now mysite
systemctl status mysite --no-pager

A healthy service shows active (running) with a master process and three workers, and about 106 MiB of memory on our VM. If it shows activating (auto-restart) with status=203/EXEC, the bin_t label is missing. A Python traceback in journalctl -u mysite instead means the settings or the environment file are wrong.

Put Nginx in front of Gunicorn

Nginx runs in the httpd_t domain, and by default SELinux won’t let it open a connection to port 8000, which the policy labels soundd_port_t. The proxy then answers 502 Bad Gateway, and the Nginx error log says “connect() to 127.0.0.1:8000 failed (13: Permission denied)”. Turn on the boolean that lets web servers connect out. It allows Nginx to connect to any TCP port, not only 8000, which is the usual trade for a reverse proxy:

sudo setsebool -P httpd_can_network_connect 1

Most Gunicorn guides use a Unix socket here, and we tried that first, with Gunicorn creating the socket itself from this unit. On Fedora it failed even at mode 777, because Gunicorn started by systemd runs as unconfined_service_t and the policy has no rule or boolean letting httpd_t connect to a socket that domain owns. We didn’t test systemd socket activation, where init creates the socket. Loopback TCP with this boolean works with the stock policy. Next, create the server block:

sudo vim /etc/nginx/conf.d/mysite.conf

Nginx serves /static/ straight from disk and passes everything else to Gunicorn with the original host and scheme. Keep the IPv6 listener even if you don’t use IPv6 yet. Fedora’s default server block listens on both, so without it an IPv6 visitor, or Let’s Encrypt validating over IPv6 for a domain with an AAAA record, gets the Fedora test page instead of your site.

server {
    listen 80;
    listen [::]:80;
    server_name django.example.com;

    location /static/ {
        alias /srv/mysite/staticfiles/;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Test the configuration, start Nginx and, where firewalld runs, open HTTP and HTTPS. Opening 443 now saves a step when the certificate arrives. On a cloud image without firewalld, open both ports in the provider’s security group instead.

sudo nginx -t
sudo systemctl enable --now nginx
sudo firewall-cmd --permanent --add-service={http,https}
sudo firewall-cmd --reload

Request a page and a static file through Nginx from the server itself, sending your host name in the Host header:

curl -s -o /dev/null -w "%{http_code}\n" -H "Host: django.example.com" http://127.0.0.1/admin/login/
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: django.example.com" http://127.0.0.1/static/admin/css/base.css

Both should return 200, which they did on Fedora 45 and in a clean replay of the deployment on Fedora 44, with no AVC denials logged. A 502 on the first means the boolean is off or Gunicorn is down. A 403 on the second, with “open() … failed (13: Permission denied)” in /var/log/nginx/error.log, means the static files still carry var_t, usually because restorecon ran before staticfiles existed. New files collected later inherit the label from the directory, so one relabel is enough unless you delete and recreate it.

Enable HTTPS for the Django site

On a server with a public DNS name and port 80 open, certbot’s Nginx plugin gets a Let’s Encrypt certificate and rewrites the server block for TLS. Fedora 44 and 45 ship that plugin without one of its dependencies. On both our Fedora 45 and Fedora 44 VMs, certbot plugins printed “The ‘certbot_nginx._internal.entrypoint’ plugin errored while loading: No module named ‘pyparsing'”. The bug is reported as Red Hat Bugzilla 2535465, so install the module alongside certbot:

sudo dnf install certbot python3-certbot-nginx python3-pyparsing
sudo certbot plugins

The plugin list now includes the Nginx entry. Without python3-pyparsing, it is missing and certbot --nginx has nothing to run.

* nginx
Description: Nginx Web Server plugin
Interfaces: Authenticator, Installer, Plugin

Request the certificate for the same domain you put in server_name, because certbot picks the server block by that name. The package enables certbot-renew.timer but doesn’t start it until the next boot, so start it too:

sudo certbot --nginx -d django.example.com
sudo systemctl enable --now certbot-renew.timer
systemctl list-timers certbot-renew.timer

Our lab VMs had no public name, so Let’s Encrypt couldn’t reach them and we never ran that first command. We terminated TLS in Nginx with a self-signed certificate instead, which exercises the Django side the same way. The timer step we did run, and list-timers showed the next renewal check within a day. Add the HTTPS settings only once the site actually answers on 443, because SECURE_SSL_REDIRECT on an HTTP-only server block sends every visitor to a port nothing listens on. Then append these four settings to the same settings.py:

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600

Keep HSTS at an hour until you’ve confirmed every page works over HTTPS, because browsers cache the header and refuse plain HTTP for that long. Raise it to a year later. Restart the app with sudo systemctl restart mysite and run check --deploy again, which now reports only two warnings:

WARNINGS:
?: (security.W005) You have not set the SECURE_HSTS_INCLUDE_SUBDOMAINS setting to True. Without this, your site is potentially vulnerable to attack via an insecure connection to a subdomain. Only set this to True if you are certain that all subdomains of your domain should be served exclusively via SSL.
?: (security.W021) You have not set the SECURE_HSTS_PRELOAD setting to True. Without this, your site cannot be submitted to the browser preload list.

System check identified 2 issues (0 silenced).

Leave those two off unless every subdomain of your domain is HTTPS only, because preload is hard to undo. One setting most guides add here you can skip in this layout. SECURE_PROXY_SSL_HEADER tells Django to trust X-Forwarded-Proto, but Gunicorn already does that for requests from 127.0.0.1, because its forwarded_allow_ips setting defaults to the loopback addresses. A direct request to Gunicorn without the header got a 301 to HTTPS, while the same request with X-Forwarded-Proto: https got a 200, so the redirect loop people warn about never happened. If Nginx and Gunicorn run on different hosts, set forwarded_allow_ips to the proxy’s address.

Sign in over HTTPS to prove that the secure CSRF cookie and the static files work through the proxy. A login that reloads the form with a CSRF error points at the cookie or the forwarded scheme, and an admin without styling points at the static location or its label:

Django admin users list served over HTTPS by Nginx and Gunicorn on Fedora

For anything beyond a small site, swap SQLite for PostgreSQL. Our PostgreSQL on Fedora guide sets up the server, and Django connects to it with the psycopg package in the same venv. Keep firewalld down to the services you serve by following the firewalld guide for Fedora.

Keep Django patched on Fedora

Django ships its security fixes only in patch releases, so staying on the latest patch of your branch matters more than any other update. How you get them depends on the route you picked.

With dnf, sudo dnf upgrade python3-django picks up patches once they leave testing, but Fedora also moves that package to new feature releases during a release’s life. Fedora 44 shipped one feature branch at launch and now delivers the next one from updates. To hold one branch with dnf, use the LTS python3-django5 package. In a venv, pin the upgrade to the feature release you run, collect static files in case the patch changed the admin’s, and restart:

sudo -u mysite /srv/mysite/.venv/bin/pip install -U "django~=$(/srv/mysite/.venv/bin/python -m django --version | cut -d. -f1,2).0"
sudo -u mysite env $(sudo cat /etc/mysite.env) /srv/mysite/.venv/bin/python /srv/mysite/manage.py collectstatic --noinput
sudo systemctl restart mysite

The ~= specifier lets pip take any patch of the installed feature release and nothing newer, so the same command stays safe after the next branch ships. On our Fedora 44 server pip pinned it to the installed feature release, found the current patch already installed and left all 130 static files unmodified. Watch the django-announce mailing list so you know when to run it.

Branch moves need a plan rather than a command. The next LTS lands in April 2027 and is the last under the old numbering, and from January 2028 Django moves to yearly calendar versions with three years of support for every release. Move a project to a new branch in this order:

  1. Copy the project and its database to a staging machine
  2. Install the new branch in its venv and run migrate
  3. Run check --deploy and your test suite
  4. Repeat on production, then collectstatic and restart

A venv points at the interpreter that built it, so if a Fedora upgrade removes or replaces that interpreter, rebuild the venv with the same python3.14 -m venv --clear step and reinstall from a pip freeze list. If you run Node.js or Go next to Django, the Node.js on Fedora and Go on Fedora guides make the same dnf versus upstream call for those runtimes. Our SELinux survival guide covers the label and boolean commands used above in more depth.

Keep reading

How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) Fedora How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Fedora Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Fedora Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Best Monitor Arms for a Multi-Monitor Developer Desk Programming Best Monitor Arms for a Multi-Monitor Developer Desk Best Mouse and Keyboard for Programming Programming Best Mouse and Keyboard for Programming How to Cut Software Development Costs by Using Linux Programming How to Cut Software Development Costs by Using Linux

Leave a Comment

Press ESC to close