vaultwarden-gvisor-sandbox¶
Modules
A NixOS module that runs a self-hosted Vaultwarden (Bitwarden-compatible) password vault as a gVisor-sandboxed podman container, built from a locally-produced, registry-free reproducible image, with nginx terminating TLS in front of it.
The problem¶
A secrets vault is about the highest-value target on your network. Two failure modes worry you most:
- A container escape or a Vaultwarden RCE reaching the host kernel.
- A poisoned or moved upstream image silently changing what you run.
This module addresses both without giving up on containers.
The approach¶
-
gVisor sandbox. The container runs under
--runtime=runsc-host. Syscalls hit gVisor's user-space kernel (runsc) instead of the host kernel, so a container escape has a much smaller and better-isolated surface to attack. The module registers therunsc-hostruntime for podman itself, so it is self-contained. -
Image built locally. Instead of
docker pull, the image is produced withdockerTools.buildLayeredImagefrom nixpkgs'vaultwardenpackage. There is no registry to trust, the build is reproducible, and the version is pinned to whatever your nixpkgs provides. -
nginx terminates TLS, proxies loopback. The container binds a loopback port; nginx does HTTPS and reverse-proxies to
127.0.0.1. Websockets are left on (proxyWebsockets = true) — without them, live vault sync between clients stops working. -
--network=host. The container bindsportdirectly and nginx reaches it on loopback with no port-mapping layer in between. Because the container shares the host network namespace,listenAddressis a host-wide bind — it defaults to127.0.0.1so the plaintext vault port is never exposed off-box. The raw port speaks unencrypted HTTP; only nginx (HTTPS) should face the network. Do not setlistenAddress = "0.0.0.0"unless you accept serving the vault API in cleartext on every interface and bypassing TLS.
The trap worth remembering¶
Vaultwarden takes optional secrets (admin token, SMTP credentials, etc.) from an
environment file. podman's environmentFiles fails the whole unit if the file
does not exist. So the service's preStart touch-es ${dataDir}/env before
start (an empty file is perfectly valid) and reclaims ownership of dataDir.
Put any secrets you need into that file as KEY=value lines — for example:
Because the vault database lives on a host bind-mount owned by a fixed uid, the
service user needs a stable numeric uid/gid. That is why uid/gid are
plain integers rather than dynamically allocated — the container runs as that
numeric id and must match the on-disk ownership.
Usage¶
{
imports = [ ./vaultwarden-gvisor-sandbox ];
services.vaultwardenSandbox = {
enable = true;
domain = "vault.example.com";
acmeHost = "vault.example.com";
};
# You provision the certificate yourself:
security.acme.certs."vault.example.com" = { /* ... */ };
}
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the module on. |
domain |
(required) | Public HTTPS domain; also passed to Vaultwarden as DOMAIN. |
acmeHost |
(required) | Name of the security.acme cert to serve (useACMEHost). |
dataDir |
/var/lib/vaultwarden |
Vault database + env file location on the host. |
port |
8222 |
Loopback port the container binds and nginx proxies to. |
listenAddress |
127.0.0.1 |
Address the container binds (ROCKET_ADDRESS). Loopback-only by default; see the warning below before changing it. |
uid / gid |
8222 |
Stable numeric id owning dataDir and running the container. |
signupsAllowed |
false |
Whether open registration is permitted (SIGNUPS_ALLOWED). |
Caveats¶
- You must provision the TLS certificate separately (
security.acme.certs.<acmeHost>); this module only references it viauseACMEHost. The module enablesservices.nginxitself, but it does not manage ACME accounts, DNS, or firewall (open 80/443 yourself). - gVisor adds a syscall-interception layer; there is a small runtime overhead and a few exotic syscalls behave differently. Vaultwarden works fine under it.
--network=hostbypasses gVisor's user-space network stack (netstack). The container shares the host network namespace, so gVisor's syscall sandboxing still applies but its network isolation dimension does not — a network-facing compromise reaches the host network stack directly. This is a deliberate tradeoff for the zero-port-mapping loopback bind; if you want the stronger netstack isolation, drop--network=hostand add an explicit port map instead (at some compat cost).- If you already register a gVisor runtime elsewhere in your configuration,
remove the runtime-registration block to avoid defining
runsc-hosttwice. - Back up
dataDir— it holds the SQLite database and attachments.
Source¶
modules/vaultwarden-gvisor-sandbox/default.nix
# vaultwarden-gvisor-sandbox
#
# Self-hosted Vaultwarden (Bitwarden-compatible) vault, running as a
# gVisor-sandboxed podman container built from a locally-produced,
# registry-free reproducible image. nginx terminates TLS and reverse-proxies
# loopback with websockets on for live vault sync.
#
# Why this shape:
# - A secrets vault is a high-value target. Running it under gVisor
# (--runtime=runsc-host) means container syscalls hit gVisor's user-space
# kernel, not the host kernel — a container escape has far less to attack.
# - The image is built with dockerTools.buildLayeredImage from nixpkgs'
# `vaultwarden`, so there is no upstream registry to trust and the image
# is reproducible and pinned.
# - `--network=host` lets the container bind `port` directly; nginx reaches
# it on 127.0.0.1 with no port-mapping layer in between.
# - The env file MUST pre-exist: `environmentFiles` fails the unit if the
# path is missing, so preStart touch-es it (empty is fine — it holds
# optional secrets like the admin token or SMTP credentials).
#
# Drop-in usage:
# imports = [ ./vaultwarden-gvisor-sandbox ];
# services.vaultwardenSandbox = {
# enable = true;
# domain = "vault.example.com";
# acmeHost = "vault.example.com"; # a security.acme cert you manage
# };
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.vaultwardenSandbox;
# Reproducible, registry-free image built from nixpkgs' vaultwarden.
vaultwardenImage = pkgs.dockerTools.buildLayeredImage {
name = "vaultwarden";
tag = "latest";
contents = with pkgs; [
vaultwarden
vaultwarden.webvault
bash
coreutils
cacert
];
config = {
Cmd = [ "${pkgs.vaultwarden}/bin/vaultwarden" ];
WorkingDir = "/data";
Env = [
"DATA_FOLDER=/data"
"WEB_VAULT_FOLDER=${pkgs.vaultwarden.webvault}/share/vaultwarden/vault"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
};
};
in
{
options.services.vaultwardenSandbox = {
enable = mkEnableOption "gVisor-sandboxed Vaultwarden vault";
dataDir = mkOption {
type = types.str;
default = "/var/lib/vaultwarden";
description = "Directory that holds the vault database and the env file.";
};
domain = mkOption {
type = types.str;
example = "vault.example.com";
description = "Public domain served over HTTPS (also passed to Vaultwarden as DOMAIN).";
};
acmeHost = mkOption {
type = types.str;
example = "vault.example.com";
description = ''
Name of the security.acme certificate to use for this vhost
(services.nginx.virtualHosts.<domain>.useACMEHost). You are expected to
provision the cert separately via security.acme.certs.<acmeHost>.
'';
};
port = mkOption {
type = types.port;
default = 8222;
description = "Loopback port the container binds and nginx proxies to.";
};
listenAddress = mkOption {
type = types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
Address the Vaultwarden container binds (ROCKET_ADDRESS). Because the
container runs with `--network=host`, this is a host-wide bind, not a
container-private one. It defaults to `127.0.0.1` so only the local
nginx front-end (which terminates TLS) can reach the vault.
Do NOT set this to `0.0.0.0` unless you fully understand the risk: the
raw listener is plaintext HTTP, so binding all interfaces exposes the
password vault API unencrypted on the LAN/tailnet/public network,
bypassing the TLS layer entirely. Off-box access should go through
nginx over HTTPS, not this port.
'';
};
uid = mkOption {
type = types.int;
default = 8222;
description = ''
Numeric uid of the service user. A fixed uid is required because it
owns dataDir on the host and is the uid the container runs as; pick any
free system uid and keep it stable.
'';
};
gid = mkOption {
type = types.int;
default = 8222;
description = "Numeric gid of the service group (see uid).";
};
signupsAllowed = mkOption {
type = types.bool;
default = false;
description = "Allow open registration of new accounts (SIGNUPS_ALLOWED).";
};
};
config = mkIf cfg.enable (mkMerge [
# --- gVisor podman runtime -------------------------------------------
# Registers a `runsc-host` OCI runtime backed by gVisor. Inlined here so
# the module is self-contained; if you already register gVisor elsewhere,
# drop this block.
{
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 "$@"''}"
];
}
# --- the vault -------------------------------------------------------
{
services.nginx.enable = true;
services.nginx.virtualHosts."${cfg.domain}" = {
forceSSL = true;
useACMEHost = cfg.acmeHost;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
proxyWebsockets = true; # required for live vault sync
};
};
users = {
users.vaultwarden = {
uid = cfg.uid;
isSystemUser = true;
group = "vaultwarden";
};
groups.vaultwarden.gid = cfg.gid;
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
];
# environmentFiles fails the unit if the path is missing, so make sure
# the env file exists (empty is fine) and reclaim ownership of dataDir.
systemd.services.podman-vaultwarden.preStart = lib.mkAfter ''
touch ${cfg.dataDir}/env
chown -R ${toString cfg.uid}:${toString cfg.gid} ${cfg.dataDir}
'';
virtualisation.oci-containers.backend = "podman";
virtualisation.oci-containers.containers.vaultwarden = {
imageFile = vaultwardenImage;
image = "vaultwarden:latest";
user = "${toString cfg.uid}:${toString cfg.gid}";
environment = {
DOMAIN = "https://${cfg.domain}";
# Under --network=host the container shares the host netns, so this
# bind address is a host-wide bind. Keep it on loopback: nginx is the
# only intended front-end and it proxies to 127.0.0.1. Binding
# 0.0.0.0 here would expose the plaintext vault API on every host
# interface (LAN/tailnet/public), bypassing TLS.
ROCKET_ADDRESS = cfg.listenAddress;
ROCKET_PORT = toString cfg.port;
ROCKET_LOG = "info";
DATA_FOLDER = "/data";
BACKUP_DIR = "/data/backup";
SIGNUPS_ALLOWED = lib.boolToString cfg.signupsAllowed;
};
environmentFiles = [ "${cfg.dataDir}/env" ];
extraOptions = [
"--runtime=runsc-host"
"--network=host"
];
volumes = [
"${cfg.dataDir}:/data"
];
};
}
]);
}