Deploy Django on NixOS With a Declarative Configuration
Deploy Django on NixOS: define Gunicorn, PostgreSQL, Redis, Nginx, and ACME certificates in configuration.nix, package the app with a Python environment, manage secrets with agenix or sops-nix, and roll back safely with generations.
Problem statement
On a traditional server, a Django deployment is a series of commands: install packages, create users, write systemd units, edit Nginx files, run Certbot. Months later nobody knows exactly what state the server is in. NixOS replaces that with one declarative configuration: users, services, packages, firewall, and certificates are described in Nix, built atomically, and every change can be rolled back by booting the previous generation.
- Enable PostgreSQL, Redis, Nginx, and ACME in
configuration.nixwith their NixOS modules. - Build a Python environment for the app with Nix (or uv inside a Nix-provided Python) and reference it from a systemd service.
- Define a
systemd.services.djangounit that runs Gunicorn withDynamicUseror a dedicated user. - Keep secrets out of the Nix store with agenix or sops-nix, loaded through
EnvironmentFile. - Deploy with
nixos-rebuild switch --target-host, and roll back withnixos-rebuild switch --rollback.
Step-by-step solution
1. Declare the supporting services
{ config, pkgs, ... }:
{
services.postgresql = {
enable = true;
package = pkgs.postgresql_17;
ensureDatabases = [ "shop" ];
ensureUsers = [{ name = "shop"; ensureDBOwnership = true; }];
};
services.redis.servers.shop = { enable = true; port = 6379; };
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
The PostgreSQL module creates the role and database on activation. Local connections over the Unix socket with peer authentication mean the shop system user needs no password.
2. Build the app's Python environment
For a small app, build the environment from nixpkgs packages:
let
pythonEnv = pkgs.python312.withPackages (ps: with ps; [
django gunicorn psycopg whitenoise django-environ celery redis
]);
src = ./shop; # or fetchGit for a pinned revision
in
For projects with many pinned dependencies, tools such as uv2nix or poetry2nix turn your lock file into Nix derivations, keeping exact versions. A pragmatic alternative is to let a systemd pre-start step run uv sync --frozen into a state directory using a Nix-provided Python and uv.
3. Define the Django service
systemd.services.shop = {
description = "Shop Django app";
after = [ "network.target" "postgresql.service" "redis-shop.service" ];
wantedBy = [ "multi-user.target" ];
environment = {
DJANGO_SETTINGS_MODULE = "config.settings.production";
DATABASE_URL = "postgres:///shop";
};
serviceConfig = {
User = "shop";
Group = "shop";
WorkingDirectory = "${src}";
StateDirectory = "shop";
RuntimeDirectory = "shop";
EnvironmentFile = config.age.secrets.shop-env.path;
ExecStartPre = "${pythonEnv}/bin/python manage.py migrate --noinput";
ExecStart = "${pythonEnv}/bin/gunicorn config.wsgi --bind unix:/run/shop/gunicorn.sock --workers 3";
Restart = "always";
};
};
users.users.shop = { isSystemUser = true; group = "shop"; };
users.groups.shop = {};
Collected static files must go to a writable path such as /var/lib/shop/static, because the source in the Nix store is read-only. Run collectstatic in ExecStartPre or at build time into a derivation.
4. Nginx and certificates
security.acme = { acceptTerms = true; defaults.email = "ops@example.com"; };
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts."shop.example.com" = {
enableACME = true;
forceSSL = true;
locations."/static/".alias = "/var/lib/shop/static/";
locations."/".proxyPass = "http://unix:/run/shop/gunicorn.sock";
};
};
users.users.nginx.extraGroups = [ "shop" ];
The ACME module obtains and renews Let's Encrypt certificates automatically.
5. Secrets and deployment
Never put secrets in Nix expressions; everything in the Nix store is world-readable. With agenix, encrypt an env file for the server's host key and reference it as config.age.secrets.shop-env.path. Deploy from your machine:
nixos-rebuild switch --flake .#shop --target-host root@shop.example.com
If something breaks, nixos-rebuild switch --rollback restores the previous generation, including the previous app version and service definitions.
Explanation
NixOS builds every package and configuration file into the immutable Nix store and activates a system "generation" by switching symlinks and restarting changed units. Because the Django service, its Python environment, and its Nginx configuration are part of the same generation, they always match. Rolling back reactivates the previous set together. The trade-off is learning Nix and packaging Python dependencies in a Nix-compatible way.
Edge cases / notes
- Python packaging. Not every PyPI package is in nixpkgs at the version you need; lock-file converters or a uv-managed venv fill the gap.
- Mutable state. Media uploads, the database, and collected static files live in
/var/lib; back them up separately from the configuration. - Celery. Add separate
systemd.servicesentries for worker and beat using the samepythonEnv.
Next steps
- Compare with the classic approach in Deploy Django With Gunicorn and Nginx on Ubuntu.
- Manage encrypted secrets with Encrypt Django Secrets With SOPS.
- Review security with the Django Server Hardening Checklist.
FAQ
Is NixOS a good choice for a first Django deployment?
Only if you already know or want to learn Nix. Ubuntu with Gunicorn and Nginx is simpler to start with.
How do I keep secrets out of the Nix store?
Use agenix or sops-nix, which decrypt secrets at activation time into files readable only by the service.
Can I roll back a bad Django release?
Yes, by switching to the previous generation, as long as database migrations are backward compatible.
Where do static files go?
In a writable state directory such as /var/lib/shop/static, served by Nginx. The Nix store is read-only.