paperless-ngx behind gVisor + podman¶
Modules
Run paperless-ngx as a gVisor-isolated podman container behind an nginx TLS reverse proxy, with dedicated loopback Redis and host-persisted state.
The problem¶
paperless-ngx ships as a container that wants Redis and a data volume. The naive
oci-containers setup gives you container-network isolation but a real kernel
attack surface (paperless is a large Python/Django + OCR stack processing
untrusted documents). This module puts a gVisor sandbox (runsc) between the
container and the host kernel instead of relying on the container's network
namespace for isolation.
The key insight (and the trap)¶
Isolation comes from runsc, not from the network namespace. Once gVisor is
the security boundary, running the container with --network=host is fine — and
it is what lets the container reach a loopback-only Redis at 127.0.0.1
without exposing Redis to anything else. The runtime is registered as a named OCI
runtime (runsc-host = runsc --network=host) and the container selects it with
--runtime=runsc-host.
Two traps this module handles for you:
-
Ownership must be pre-seeded. systemd-tmpfiles creates every bind-mount dir (
data,media,consume,export)0700owned byuid/gidbefore the container starts. The image'sUSERMAP_UID/USERMAP_GIDthen run paperless as that same id. If the dirs don't exist with that owner on a freshdataDir, first-run writes fail with permission errors. -
Public URL must match.
PAPERLESS_URLandPAPERLESS_CSRF_TRUSTED_ORIGINSare set tohttps://<domain>. Behind a reverse proxy, if these don't equal the URL the browser actually uses, login and CSRF break.
Ordering: when this module manages Redis, podman-paperless is set after /
requires redis-paperless, so Redis is up before paperless dials it.
Usage¶
{
imports = [ ./modules/paperless-ngx-gvisor-podman ];
services.paperlessGvisor = {
enable = true;
domain = "paperless.example.com";
# everything below is optional — shown with its default
# dataDir = "/var/lib/paperless";
# uid = 2800;
# gid = 2800;
# port = 2800;
# image = "ghcr.io/paperless-ngx/paperless-ngx:latest";
# acmeHost = null; # reuse a named ACME cert; null => vhost gets its own
# filenameFormat = "{correspondent}/{created_year}/{title}";
};
}
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the service on. |
domain |
null (required) |
Public host; drives the vhost, PAPERLESS_URL, CSRF origins. |
image |
…paperless-ngx:latest |
Container image. Unpinned by default — see caveats. |
dataDir |
/var/lib/paperless |
Host state root (bind-mounted, survives image churn). |
uid / gid |
2800 |
Id paperless runs as; bind-mount dirs are chowned to it. |
port |
2800 |
Host port paperless listens on (real host port — --network=host). |
redisPort |
6379 |
Loopback Redis port. |
bindAddress |
127.0.0.1 |
Address paperless binds (PAPERLESS_BIND_ADDR); loopback keeps the app behind TLS only. |
acmeHost |
null |
Reuse a named ACME cert; null lets the vhost enable its own ACME. |
filenameFormat |
null |
PAPERLESS_FILENAME_FORMAT; null keeps the image default. |
extraEnvironment |
{} |
Extra env vars merged into the container. |
manageNginx |
true |
Set false to wire your own reverse proxy to 127.0.0.1:<port>. |
manageRedis |
true |
Set false to point at your own Redis via extraEnvironment. |
Caveats¶
:latestis unpinned. The default image tag means a rebuild/redeploy silently pulls whatever ghcr currently publishes. Pin to a digest or version tag (image = "…paperless-ngx@sha256:…") for reproducible deploys.portis a real host port. Because of--network=host,portandredisPortoccupy the host's port space directly — make sure they're free and don't collide with other services.- Cleartext app port / TLS bypass. With
--network=host, whatever the app binds to is a real host bind. This module defaultsbindAddressto127.0.0.1so paperless is reachable only through the nginx TLS front. If you setbindAddress = "0.0.0.0", the plaintext login and document store are exposed onporton every interface, bypassing TLS/HSTS — keep that port firewalled and never rely on the app's own auth over plaintext. Even on loopback, any lower-trust process on the same host can reach the app directly. - gVisor overhead.
runscadds syscall-interception overhead; OCR of large batches will be somewhat slower than a native container. That's the cost of the sandbox. uid/gid/portsharing a number in the example is just a convenience, not a requirement — pick any free values.- Requires
pkgs.gvisorto be available and your kernel to permitrunsc(it usesptrace/KVM platform depending on host config).
What it configures¶
virtualisation.podman+ arunsc-hostOCI runtime (gVisor with host net).services.redis.servers.paperlessbound to127.0.0.1(whenmanageRedis).services.nginx.virtualHosts.<domain>with TLS + websocket proxy (whenmanageNginx).systemd.tmpfilesrules seeding the bind-mount dirs with correct ownership.- The
paperlessOCI container with the env, volumes and runtime flags above.
Source¶
modules/paperless-ngx-gvisor-podman/default.nix
# paperless-ngx-gvisor-podman
#
# Run paperless-ngx as a gVisor-isolated podman container behind nginx + TLS.
#
# The security boundary is gVisor's `runsc` sandbox, NOT the container's network
# namespace. Because runsc is the isolation layer, the container runs with
# `--network=host` so it can reach a loopback-only Redis on the host at
# 127.0.0.1. The two traps this module bakes in:
#
# 1. tmpfiles pre-creates every bind-mount dir owned by exactly the uid/gid
# that the image's USERMAP_UID/USERMAP_GID map paperless to. Skip this and
# first-run writes fail with permission errors on a fresh dataDir.
# 2. PAPERLESS_URL / PAPERLESS_CSRF_TRUSTED_ORIGINS must equal the public
# https URL, or login/CSRF breaks behind the reverse proxy.
#
# Drop-in: import this module and set `services.paperlessGvisor.enable = true`
# plus `domain`. See README.md for the full option list and caveats.
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkMerge
mkOption
optionalAttrs
types
;
cfg = config.services.paperlessGvisor;
in
{
options.services.paperlessGvisor = {
enable = mkEnableOption "paperless-ngx as a gVisor-isolated podman container";
domain = mkOption {
type = types.nullOr types.str;
default = null;
example = "paperless.example.com";
description = ''
Public domain the reverse proxy serves paperless on. Must be set when
enabled. Used verbatim for PAPERLESS_URL and CSRF trusted origins, so it
must match what users type in the browser.
'';
};
image = mkOption {
type = types.str;
default = "ghcr.io/paperless-ngx/paperless-ngx:latest";
description = ''
Container image to run. Defaults to the upstream `:latest` tag, which is
unpinned — a redeploy silently pulls whatever ghcr currently publishes.
Pin to a digest or a version tag for reproducible deploys.
'';
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/paperless";
description = ''
Host directory holding paperless state (data/media/consume/export). It
and its subdirs are created by tmpfiles, owned by uid/gid, and bind
mounted into the container so state survives image churn.
'';
};
uid = mkOption {
type = types.int;
default = 3500;
description = ''
Numeric uid the container runs paperless as (via the image's
USERMAP_UID). The bind-mount dirs are chowned to this id so paperless can
write them. The specific number is arbitrary — pick one that does not
collide with other users on the host.
'';
};
gid = mkOption {
type = types.int;
default = 3500;
description = "Numeric gid paired with `uid` (via the image's USERMAP_GID).";
};
port = mkOption {
type = types.port;
default = 3500;
description = ''
Port paperless listens on. Because the container is `--network=host`,
this is a real host port — it must be free on the host and must match the
reverse-proxy upstream.
'';
};
redisPort = mkOption {
type = types.port;
default = 6379;
description = "Loopback port for the paperless-dedicated Redis instance.";
};
bindAddress = mkOption {
type = types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
Address paperless binds to (PAPERLESS_BIND_ADDR). Because the container
is `--network=host`, this is a real host bind. It defaults to loopback so
the app is reachable only through the TLS reverse proxy, never in
cleartext on other interfaces. Only widen this (e.g. `0.0.0.0`) if you
deliberately want the plaintext app port exposed and keep it firewalled.
'';
};
acmeHost = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
description = ''
Name of the ACME certificate to reuse for the nginx vhost
(services.nginx.virtualHosts.<domain>.useACMEHost). Leave null to let
nginx obtain its own cert for `domain` via the enableACME path you
configure elsewhere.
'';
};
filenameFormat = mkOption {
type = types.nullOr types.str;
default = null;
example = "{correspondent}/{created_year}/{created_year}.{created_month}.{created_day}-{title}";
description = ''
Value for PAPERLESS_FILENAME_FORMAT (how paperless lays out stored
originals). Null leaves the image default.
'';
};
extraEnvironment = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Extra environment variables merged into the container.";
};
manageNginx = mkOption {
type = types.bool;
default = true;
description = ''
Whether this module configures the nginx virtualHost for `domain`. Set
false to wire your own reverse proxy to http://127.0.0.1:<port>/.
'';
};
manageRedis = mkOption {
type = types.bool;
default = true;
description = ''
Whether this module runs a dedicated loopback Redis for paperless. Set
false and point extraEnvironment.PAPERLESS_REDIS at your own instance.
'';
};
};
config = mkIf cfg.enable (mkMerge [
# ---- assertions ---------------------------------------------------------
{
assertions = [
{
assertion = cfg.domain != null;
message = "services.paperlessGvisor: domain must be set when enabled.";
}
];
}
# ---- gVisor podman runtime ---------------------------------------------
# This is where the isolation lives. `runsc-host` is a runsc invocation with
# host networking, registered as a named OCI runtime that the container below
# selects with `--runtime=runsc-host`.
{
virtualisation.podman = {
enable = true;
extraPackages = [ pkgs.gvisor ];
};
virtualisation.containers.containersConf.settings.engine.runtimes.runsc-host = [
"${pkgs.writeShellScript "runsc-host" ''exec ${pkgs.gvisor}/bin/runsc --network=host "$@"''}"
];
}
# ---- reverse proxy ------------------------------------------------------
(mkIf cfg.manageNginx {
services.nginx.virtualHosts.${cfg.domain} = {
forceSSL = true;
enableACME = cfg.acmeHost == null;
useACMEHost = cfg.acmeHost;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
proxyWebsockets = true;
recommendedProxySettings = true;
};
};
})
# ---- loopback Redis -----------------------------------------------------
(mkIf cfg.manageRedis {
services.redis.servers.paperless = {
enable = true;
port = cfg.redisPort;
bind = "127.0.0.1";
};
# Redis must be up before paperless dials it.
systemd.services.podman-paperless = {
after = [ "redis-paperless.service" ];
requires = [ "redis-paperless.service" ];
};
})
# ---- container + state --------------------------------------------------
{
# TRAP: pre-create every bind-mount dir 0700 owned by uid/gid *before* the
# container starts. USERMAP_UID/GID below run paperless as that same id, so
# matching ownership is what makes first-run writes succeed.
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/data 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/media 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/consume 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.dataDir}/export 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
];
virtualisation.oci-containers.backend = "podman";
virtualisation.oci-containers.containers.paperless = {
image = cfg.image;
environment = {
PAPERLESS_REDIS = "redis://127.0.0.1:${toString cfg.redisPort}";
PAPERLESS_URL = "https://${cfg.domain}";
PAPERLESS_CSRF_TRUSTED_ORIGINS = "https://${cfg.domain}";
PAPERLESS_PORT = toString cfg.port;
PAPERLESS_BIND_ADDR = cfg.bindAddress;
PAPERLESS_DATA_DIR = "/usr/src/paperless/data";
PAPERLESS_MEDIA_ROOT = "/usr/src/paperless/media";
PAPERLESS_CONSUMPTION_DIR = "/usr/src/paperless/consume";
USERMAP_UID = toString cfg.uid;
USERMAP_GID = toString cfg.gid;
}
// optionalAttrs (cfg.filenameFormat != null) {
PAPERLESS_FILENAME_FORMAT = cfg.filenameFormat;
}
// cfg.extraEnvironment;
extraOptions = [
# Isolation comes from runsc, not the netns...
"--runtime=runsc-host"
# ...so host networking is safe here, and it lets the container reach
# the loopback-only Redis at 127.0.0.1.
"--network=host"
];
volumes = [
"${cfg.dataDir}/data:/usr/src/paperless/data"
"${cfg.dataDir}/media:/usr/src/paperless/media"
"${cfg.dataDir}/consume:/usr/src/paperless/consume"
"${cfg.dataDir}/export:/usr/src/paperless/export"
];
};
}
]);
}