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.

Django on NixOSconfiguration.nixservices declarednixos-rebuildbuilds generationSwitchunits, users, nginxRollbackprevious generationThe whole server, including the Django service, is built from one configuration.
  1. Enable PostgreSQL, Redis, Nginx, and ACME in configuration.nix with their NixOS modules.
  2. Build a Python environment for the app with Nix (or uv inside a Nix-provided Python) and reference it from a systemd service.
  3. Define a systemd.services.django unit that runs Gunicorn with DynamicUser or a dedicated user.
  4. Keep secrets out of the Nix store with agenix or sops-nix, loaded through EnvironmentFile.
  5. Deploy with nixos-rebuild switch --target-host, and roll back with nixos-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.

how the server state is definedCommands run over time✕state drifts from docs✕rollback is manual✕hard to rebuild exactlyconfiguration.nix✓one file describes it✓boot previous generation✓same config, same serverNixOS trades a learning curve for reproducible serversdjango-deployment.com
Pin nixpkgs with flakes so a rebuild next year uses the same package versions.

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.

from commit to running service1Update src or flake inputnew app revision2nixos-rebuild buildcatches errors early3switch --target-hostatomic activation4ExecStartPre migratesthen Gunicorn starts5Rollback if neededprevious generationA failed build never touches the running serverdjango-deployment.com
Database migrations are the one part a NixOS rollback cannot undo; keep them backward compatible.

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.services entries for worker and beat using the same pythonEnv.

Next steps

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.

Keep going