Larvitz Blog

FreeBSD, Linux, all things cleanly engineered

Self-Hosted CryptPad on FreeBSD with VNET Jails and Caddy



CryptPad is an end-to-end encrypted collaboration suite. Think Google Docs, but where the server never sees your content. It’s a compelling option for privacy-conscious teams or anyone wanting to own their data. This post documents installing CryptPad in a FreeBSD VNET jail, served behind a Caddy reverse proxy, with network isolation enforced by PF.

This guide assumes familiarity with FreeBSD jails, PF, and BastilleBSD. It focuses on architecture and pitfalls rather than introductory jail setup.

Cryptpad

Why CryptPad?

Most collaborative document platforms require trusting the provider with your data. CryptPad takes a different approach: all encryption happens client-side in the browser. The server stores only encrypted blobs and has no way to decrypt your documents, even if compelled to.

Self-hosting adds another layer: your data never leaves infrastructure you control. Combined with FreeBSD’s VNET jails for network isolation, this creates a robust privacy-respecting setup. Unlike tools such as Nextcloud or OnlyOffice, CryptPad’s threat model assumes the server itself is untrusted.

Architecture Overview

[ Internet ]
     |
     v
+-----------------------+
|    PF Firewall        |
|    NAT + RDR          |
+-----------+-----------+
            |
     +------+------+
     | bastille0   |  (bridge interface)
     | 10.254.254.1|
     +------+------+
            |
   +--------+--------+
   |                 |
   v                 v
+-------+      +-----------+
| Caddy |      | CryptPad  |
| Jail  |      |   Jail    |
| .10   |----->|   .36     |
+-------+      +-----------+

The key security principle: jails live on a private RFC1918 network (10.254.254.0/24) that is completely invisible to the internet. PF on the host performs NAT for outbound traffic and only redirects specific ports to specific jails. The CryptPad jail has no direct internet exposure - all traffic flows through the Caddy jail.

VNET Jail Networking

This setup uses VNET jails, which give each jail its own complete network stack including interfaces, routing tables, and firewall state. Unlike traditional IP-based jails that share the host’s network stack, VNET jails behave like independent networked machines.

The jails attach to a bridge interface on the host:

# Host /etc/rc.conf (relevant networking section)
cloned_interfaces="bridge0"
ifconfig_bridge0_name="bastille0"
ifconfig_bastille0="inet 10.254.254.1/24"
gateway_enable="YES"

The host acts as the default gateway for all jails. Each jail gets an IP from the 10.254.254.0/24 range:

$ bastille list
 JID  Name           State  IP Address
 2    caddy          Up     10.254.254.10
 10   cryptpad       Up     10.254.254.36
 ...

Creating the CryptPad jail with Bastille:

bastille create -B cryptpad 15.0-RELEASE 10.254.254.36 bastille0

The -B flag creates a VNET jail attached to the bastille0 bridge.

PF Firewall: NAT and Selective Exposure

The critical security layer is PF on the host. It provides:

  1. NAT for outbound traffic - jails can reach the internet (for package updates, etc.) but appear to come from the host’s public IP
  2. Port redirection - only ports 80/443 are exposed, and only to the Caddy jail
  3. Default deny - everything else is silently dropped
# /etc/pf.conf
ext_if = "vtnet0"
jail_net = "10.254.254.0/24"
frontend_v4 = "10.254.254.10"  # Caddy jail

table <jails_v4> { $jail_net }

set skip on lo0
set block-policy drop

# NAT: Jails -> Internet
nat on $ext_if inet from <jails_v4> to any -> ($ext_if)

# RDR: Internet -> Caddy jail only
rdr pass on $ext_if inet proto tcp to ($ext_if) port {80,443} -> $frontend_v4

# Default deny
block drop in log all
block drop out log all

# Allow established connections out
pass out quick all keep state

# Anti-spoofing
antispoof quick for { $ext_if, bastille0 }

# ICMP
pass in inet proto icmp icmp-type { echoreq, unreach }

# Jail egress (but not to other jails)
pass in quick on bastille0 from <jails_v4> to ! 10.254.254.0/24 keep state

Notice what’s not here: there’s no rule allowing direct internet access to the CryptPad jail. Traffic to ports 80/443 is redirected to 10.254.254.10 (Caddy), which then proxies to CryptPad internally over the bridge network. The CryptPad jail is completely hidden from the internet.

This is defense in depth: even if CryptPad had a vulnerability, an attacker couldn’t establish a reverse shell or exfiltrate data directly - they’d have to go through Caddy first.

CryptPad Installation

Two domains are required: one for the main application and one for the sandbox (CryptPad’s XSS protection mechanism). Both domains resolve to the host’s public IP, where PF redirects them to Caddy.

Prerequisites

Inside the CryptPad jail, install the necessary packages. CryptPad requires Node.js 20+ and build tools for native modules:

pkg install git node24 npm-node24 gmake python3 bash

The full package list for a working installation looks like this:

brotli-1.2.0,1          git-tiny-2.52.0         node24-24.12.0
c-ares-1.34.6           gmake-4.4.1             npm-node24-11.7.0
corepack-0.34.5         icu-76.1,1              python311-3.11.14
libuv-1.51.0            llhttp-9.3.0            simdjson-4.2.4

User and Installation

Never run CryptPad as root. Create a dedicated user:

pw useradd -n cryptpad -c "CryptPad User" -d /usr/local/cryptpad -s /bin/sh -m
su - cryptpad
git clone https://github.com/cryptpad/cryptpad.git .

The Critical Installation Step

This is where many installations fail. Running only npm install will leave you with a site that hangs on “Loading…” and throws 404 errors for require.js. CryptPad needs three distinct build steps:

# 1. Install backend dependencies
npm install

# 2. Install frontend components (bower, bootstrap, require.js, etc.)
npm run install:components

# 3. Build static assets (pages, CSS, etc.)
npm run build

The second step is easily missed because it’s not obvious from the documentation. If you skip it, the application will appear to start but the browser will never finish loading.

Configuration

Copy the example configuration and edit it:

cp config/config.example.js config/config.js

The critical settings in config/config.js:

module.exports = {
    // Listen on the jail's interface (not localhost)
    httpAddress: '10.254.254.36',

    // Main domain - where users access CryptPad
    httpUnsafeOrigin: 'https://pad.example.com',

    // Sandbox domain - MUST be different from main domain
    httpSafeOrigin: 'https://sandbox.example.com',

    // Admin keys (add after registering your admin account)
    adminKeys: [
        // "[your-public-key-here]"
    ],
};

Understanding the Two Domains

CryptPad’s security model relies on browser same-origin policy. The main domain handles authentication and cryptographic operations. The sandbox domain loads the UI in an iframe with a different origin, preventing XSS attacks from accessing your encryption keys.

Both domains point to the same CryptPad instance, the application routes requests appropriately based on the Host header. This is why correctly passing the Host header through the reverse proxy is essential.

FreeBSD Service Setup

CryptPad includes an rc.d script template. Copy it and enable the service:

cp /usr/local/cryptpad/docs/rc.d-cryptpad /usr/local/etc/rc.d/cryptpad
chmod +x /usr/local/etc/rc.d/cryptpad
sysrc cryptpad_enable="YES"

Here’s the actual rc.d script adapted for FreeBSD:

#!/bin/sh
# PROVIDE: cryptpad
# REQUIRE: DAEMON
# KEYWORD: shutdown

. /etc/rc.subr

name="cryptpad"
start_cmd="start"
stop_cmd="stop"
rcvar=cryptpad_enable

pidfile="/var/run/cryptpad/${name}.pid"
desc="CryptPad Service"

load_rc_config ${name}

start() {
    /bin/mkdir -p /var/run/cryptpad
    /usr/sbin/chown cryptpad:cryptpad /var/run/cryptpad

    /usr/bin/su cryptpad -c "export PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:~/bin && \
        cd /usr/local/cryptpad && \
        /usr/sbin/daemon -T ${name} \
            -P /var/run/cryptpad/${name}_supervisor.pid \
            -p /var/run/cryptpad/${name}.pid \
            -f -S -r /usr/local/bin/node server"
}

stop() {
    /bin/kill -9 `cat /var/run/cryptpad/${name}_supervisor.pid`
    /bin/kill -15 `cat /var/run/cryptpad/${name}.pid`
}

run_rc_command "$1"

Start the service:

service cryptpad start

A running CryptPad instance spawns multiple worker processes:

USER       PID %CPU %MEM    VSZ   RSS TT  STAT COMMAND
cryptpad 89849  0.0  0.0  13068  2508  -  IsJ  daemon: /usr/local/bin/node[89850]
cryptpad 89850  0.0  1.1 839584 87964  -  SJ   /usr/local/bin/node server
cryptpad 89851  0.0  1.1 840864 88624  -  SJ   /usr/local/bin/node ./lib/http-worker.js
cryptpad 89852  0.0  1.1 842400 88000  -  SJ   /usr/local/bin/node ./lib/http-worker.js
...
cryptpad 89857  0.0  1.0 834088 79448  -  SJ   /usr/local/bin/node lib/workers/db-worker

Caddy Reverse Proxy

Caddy runs in its own jail (10.254.254.10) and is the only jail exposed to the internet via PF’s port redirection. Configure it to proxy both CryptPad domains to the application jail. The Host header passthrough is crucial! Without it, CryptPad’s WebSocket security checks will fail:

pad.example.com {
    reverse_proxy 10.254.254.36:3000 {
        header_up Host {host}
        header_up X-Real-IP {remote}
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubdomains"
        X-XSS-Protection "1; mode=block"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "same-origin"
    }
}

sandbox.example.com {
    reverse_proxy 10.254.254.36:3000 {
        header_up Host {host}
        header_up X-Real-IP {remote}
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubdomains"
    }
}

Note that both domains proxy to the same backend (10.254.254.36:3000). Caddy handles TLS automatically via Let’s Encrypt. Since the jails communicate over the private bridge network, this internal traffic doesn’t need encryption.

Troubleshooting

Site Hangs on “Loading…”

This is almost always caused by missing frontend components. Check if require.js exists:

ls -l /usr/local/cryptpad/www/components/requirejs/require.js

If missing, you skipped npm run install:components. Fix it:

su - cryptpad
cd /usr/local/cryptpad
npm run install:components
npm run build
exit
service cryptpad restart

WebSocket Connection Failures

If you see WebSocket errors in the browser console, verify:

  1. The Host header is being passed through your reverse proxy
  2. Both domains are correctly configured in config.js
  3. The httpAddress in config.js matches the jail’s IP

White Screen

Usually a sandbox domain misconfiguration. The main and sandbox domains must be different but both must resolve to your CryptPad instance. Check browser console for CORS or CSP errors.

Adding an Admin Account

After the first successful login:

  1. Register a normal account through the web interface
  2. Go to Settings and find your public signing key
  3. Add it to the adminKeys array in config/config.js
  4. Restart CryptPad
adminKeys: [
    "[cryptpad-admin@pad.example.com/YZgXQxKR0Rcb6r6CmxHPdAGLVludrAF2lEnkbx1vVOo=]",
],

The admin panel then becomes available at https://pad.example.com/admin/.

Conclusion

CryptPad in a FreeBSD VNET jail provides a solid foundation for privacy-respecting collaboration. The layered security model gives defense in depth:

  • VNET jails provide complete network stack isolation
  • PF NAT hides all jails behind RFC1918 addresses
  • Selective port redirection exposes only the Caddy frontend
  • End-to-end encryption means even you as administrator cannot read documents

The main gotcha is the three-step build process - if your installation hangs on loading, that’s almost certainly the culprit. Get that right, ensure both domains are configured, and you’ll have a working instance that’s invisible to port scanners and attackers probing for Node.js vulnerabilities.


References