Strapi 5 uses HMAC-SHA512 (not plain SHA-512) for access_key and AES-256-GCM for encrypted_key. Both columns must be updated together and Strapi must be restarted after any direct DB token changes. Includes reusable gen-token.mjs script and correct bastille service management commands. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- Build: pass | Tests: pass — Tests 942 passed (942)
20 KiB
Strapi 5 on FreeBSD Jails — Complete Installation Guide
Last Updated: April 12, 2026 Status: Complete end-to-end walkthrough Tested On: FreeBSD 15.0-RELEASE-p4, Node.js 24.13.0
Executive Summary
Strapi 5 runs on FreeBSD inside Bastille jails, but requires solving two native binding blockers:
@swc/corehas no pre-built FreeBSD binary (solved: compile from Rust nightly)sharp(libvips) requires system package install
Once these are solved, the standard Strapi v5 installation process works unchanged.
Total time estimate (fresh start to admin panel): 2–3 hours
- Jail provisioning: 15 min
- System packages: 5 min
npm install: 10 min- @swc/core Rust build: 40–60 min (nightly toolchain + compilation)
- Strapi admin build: 5 min
- First admin user creation: 5 min
Architecture
Host (FreeBSD 15.0)
└── Bastille Jail: cms (VNET)
├── nginx 1.28.0 (serves docs + proxies Strapi)
├── PostgreSQL client
└── Node.js 24.13.0
└── Strapi 5.0.0
├── @strapi/strapi (main app)
├── @swc/core 1.15.24 (native JS compiler — FreeBSD binary built from source)
├── sharp (image processing via system libvips)
└── pg (PostgreSQL driver)
Database: External PostgreSQL jail (db @ 10.0.1.3)
└── strapi_cms database
Blockers Encountered & Solutions
Blocker 1: @swc/core Missing FreeBSD Binary
Problem:
- Strapi uses
@swc/core1.15.24 for TypeScript/JSX transpilation - npm's
@swc/corepackage ships pre-built binaries for Linux, macOS, Windows only - The package.json includes optional dependencies like
@swc/core-linux-x64-gnu, but no@swc/core-freebsd-x64 - When
npm installruns, the FreeBSD binary fails to load
Error signature:
Failed to load native binding
Error: Cannot find module '@swc/core'
Root cause:
@swc/coreis compiled from Rust using NAPI-RS- Pre-built binaries (.node files) are published per-platform
- FreeBSD binaries are not part of the standard npm distribution
Solution: Build the native module from source on FreeBSD:
-
Clone SWC repository (matching @swc/core version):
git clone https://github.com/swc-project/swc.git /tmp/swc cd /tmp/swc git checkout v1.15.24 # Match npm package version -
Install Rust + nightly toolchain (on the host):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh . "$HOME/.cargo/env" rustup toolchain install nightly-2026-04-10 # SWC's rust-toolchain -
Build the native binding (4 CPU cap, takes 40–60 min):
cd /tmp/swc CARGO_BUILD_JOBS=4 cargo +nightly-2026-04-10 build --release -p binding_core_node 2>&1 | tee build.logOutput:
target/release/deps/libbinding_core_node.so -
Copy binary into Strapi's @swc/core:
cp target/release/deps/libbinding_core_node.so \ /usr/local/bastille/jails/cms/root/home/clawdie/strapi/node_modules/@swc/core/swc.freebsd-x64.node -
Verify (inside cms jail):
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node -e \"require('@swc/core'); console.log('OK')\""
Why Nightly?
- SWC uses unstable Rust features (the
-Zcompiler flags in its build scripts) - These are only available on nightly, not stable Rust
- The
rust-toolchainfile in SWC repo pins the exact nightly date
Fallback (if nightly build fails OR TLS exhaustion error):
cargo +nightly-2026-04-10 build --release -p binding_core_node \
--no-default-features --features swc_v1
This disables the plugin feature (which includes Wasmer) but keeps core functionality working.
If you see: "No space available for static Thread Local Storage" This means the binary includes Wasmer plugins which consume too much TLS space on FreeBSD jails. Use the fallback build above — it's much smaller and sufficient for Strapi's transpilation needs.
Blocker 2: sharp (libvips) Not Installed
Problem:
- Strapi's media library uses
sharpfor image processing sharprequireslibvipssystem library- npm install fails with:
Could not find vips on your system
Solution:
sudo pkg install vips
This installs libvips (11.4.x) + dependencies. After this, npm install completes successfully.
Step-by-Step Installation
Step 0: Prerequisites (Host)
# Verify FreeBSD 15.0+
uname -r
# Verify Rust is installed
rustup --version # Should show 1.29.0+
# Verify nightly toolchain (from blocker section)
rustup toolchain install nightly-2026-04-10
Step 1: Create & Configure CMS Jail
Assume Bastille is already set up with a warden0 bridge.
# Create VNET jail
sudo bastille create -B -r 15.0-RELEASE cms warden0 10.0.1.4
# Start jail
sudo bastille start cms
# Install base packages
sudo bastille cmd cms pkg install -y nginx node24 npm-node24 postgresql15-client
Why these packages?
nginx— reverse proxy for Strapi + static docsnode24+npm-node24— required pair on FreeBSD (see memory)postgresql15-client— psql CLI for testing DB connectivity
Step 2: Install System Dependencies
# libvips for sharp (image processing)
sudo bastille cmd cms pkg install -y vips
Verify:
sudo bastille cmd cms pkg info | grep vips
Step 3: Create Strapi Directory & Install npm Packages
# Create app directory
sudo bastille cmd cms mkdir -p /home/clawdie/strapi
sudo bastille cmd cms chown clawdie:clawdie /home/clawdie/strapi
# Create package.json (minimal Strapi v5 config)
sudo bastille cmd cms su -m clawdie -c "cat > /home/clawdie/strapi/package.json <<'EOF'
{
\"name\": \"clawdie-cms\",
\"private\": true,
\"version\": \"0.6.0\",
\"scripts\": {
\"develop\": \"strapi develop\",
\"start\": \"strapi start\",
\"build\": \"strapi build\"
},
\"dependencies\": {
\"@strapi/strapi\": \"^5.0.0\",
\"pg\": \"^8.20.0\",
\"react\": \"^18.3.1\",
\"react-dom\": \"^18.3.1\",
\"react-router-dom\": \"^6.30.3\",
\"styled-components\": \"^6.4.0\"
},
\"engines\": {
\"node\": \">=20\"
}
}
EOF"
# Install npm dependencies
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && npm install" 2>&1 | tee /tmp/strapi-npm-install.log
Expected output: 897 packages installed
Step 4: Set Strapi Environment
sudo bastille cmd cms su -m clawdie -c "cat > /home/clawdie/strapi/.env <<'EOF'
DATABASE_HOST=10.0.1.3
DATABASE_PORT=5432
DATABASE_NAME=strapi_cms
DATABASE_USERNAME=strapi_cms
DATABASE_PASSWORD=Z0J2dqopv6xnwAdwhqXVTn2tmJfkS8bs
APP_KEYS=2Q8aLUuNl+XYtLOTR5id+jxrbvR9wEq4sGkAc4bhhJg=
API_TOKEN_SALT=ff6tfcX++W/YYl4+IHN9tC48uWZ+rdCEoRttZtE8IZ4=
ADMIN_JWT_SECRET=5fhgh8k9bCeJJ00Y8oTkIBD3G2MBMwuw
ADMIN_ENCRYPTION_KEY=replace-me-32-plus
TRANSFER_TOKEN_SALT=qnlcWwbw0enMJOrkTvJ4te78HxVyhiJ_
JWT_SECRET=I/5BVORVQvgoyvDhimymz0eisL0ztWFa406ZDpc4yI0=
# Reduce memory footprint for FreeBSD jails
NODE_OPTIONS=--max-old-space-size=512
EOF"
Keys to generate (for your own install):
# Use openssl or head /dev/urandom
openssl rand -base64 32 # For each *_SALT and *_SECRET
Step 5: Solve @swc/core (Blocking Step)
This is the critical blocker. Run on the host (not in jail):
# 1. Clone SWC at matching version
git clone https://github.com/swc-project/swc.git /tmp/swc
cd /tmp/swc
git checkout v1.15.24
# 2. Install nightly toolchain (one-time)
. "$HOME/.cargo/env"
rustup toolchain install nightly-2026-04-10
# 3. Build the native binding (40–60 min, 4 CPUs capped)
CARGO_BUILD_JOBS=4 cargo +nightly-2026-04-10 build --release -p binding_core_node 2>&1 | tee /tmp/swc-build.log
# 4. Copy binary into cms jail
cp /tmp/swc/target/release/deps/libbinding_core_node.so \
/usr/local/bastille/jails/cms/root/home/clawdie/strapi/node_modules/@swc/core/swc.freebsd-x64.node
Verify (inside jail):
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node -e \"require('@swc/core'); console.log('OK')\""
Expected: OK
Step 6: Build Strapi Admin Panel
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && npm run build" 2>&1 | tee /tmp/strapi-build.log
Expected output:
✔ Building Strapi admin...
✔ Admin built successfully
This compiles the React admin UI to dist/. Takes ~5 min.
Step 7: Configure nginx Proxy
The cms jail's nginx should proxy /strapi/ to port 1337. Add to /usr/local/etc/nginx/nginx.conf:
http {
# ... existing config ...
upstream strapi_backend {
server 127.0.0.1:1337;
}
server {
listen 80;
server_name _;
root /usr/local/www/clawdie;
location / {
try_files $uri $uri/ /index.html; # Docs
}
location /strapi/ {
proxy_pass http://strapi_backend/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /screenshots/ {
auth_basic "Restricted";
auth_basic_user_file /usr/local/etc/nginx/.htpasswd;
autoindex on;
}
}
}
Reload nginx:
sudo bastille cmd cms nginx -s reload
Step 8: Start Strapi
sudo bastille cmd cms su -l clawdie -c "cd /home/clawdie/strapi && npm run start"
First run (Strapi will initialize):
- Checks database connectivity
- Runs migrations
- Creates
public/uploadsif missing (or errors) - Prompts to create first admin user
- Opens admin panel at
http://10.0.1.4:1337/admin
Step 9: Create First Admin User
If Strapi complains about the upload folder, create it first:
sudo bastille cmd cms su -l clawdie -c "mkdir -p /home/clawdie/strapi/public/uploads"
Create the admin user from the prompt or via CLI:
sudo bastille cmd cms su -l clawdie -c "cd /home/clawdie/strapi && npx strapi admin:create-user \
--email admin@example.com --firstname Admin --lastname User --password 'ChangeMe123!'"
Then:
# Test from host
curl -s http://10.0.1.4:1337/admin/ | head -5
Expected: HTML admin panel
Step 10: Expose Admin via Tailscale (Host Nginx)
Use the host MagicDNS name and a dedicated port to avoid conflicts with the controlplane vhost on 443.
Create a vhost on the host (use your own MagicDNS hostname):
server {
listen 8443 ssl;
server_name HOSTNAME.TAILNET.ts.net;
ssl_certificate /usr/local/etc/nginx/ssl/tailscale/HOSTNAME.TAILNET.ts.net.crt;
ssl_certificate_key /usr/local/etc/nginx/ssl/tailscale/HOSTNAME.TAILNET.ts.net.key;
location / {
proxy_pass http://10.0.1.4:1337;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
PF rule (host):
pass in quick on $tailscale_if proto tcp to port 8443 keep state
Reload:
sudo pfctl -nf /etc/pf.conf
sudo service pf reload
sudo service nginx reload
Access: https://HOSTNAME.TAILNET.ts.net:8443/admin
Time Breakdown
| Step | Time | Notes |
|---|---|---|
| Jail creation + packages | 15 min | One-time; parallel OK |
| npm install | 10 min | All 897 packages |
| SWC build (nightly) | 40–60 min | First run slower; 4 CPUs capped |
| Strapi admin build | 5 min | React compilation |
| Start & first admin | 5 min | DB migrations |
| Total | 75–95 min | ~2.5 hours fresh |
Troubleshooting
@swc/core Still Fails After Binary Copy
Symptom: Failed to load native binding after copying .so
Check:
file /usr/local/bastille/jails/cms/root/home/clawdie/strapi/node_modules/@swc/core/swc.freebsd-x64.node
# Should output: ELF 64-bit LSB shared object, x86-64, version 1 (FreeBSD), dynamically linked
Fix: Ensure the binary was built with the correct Rust toolchain:
ldd /tmp/swc/target/release/deps/libbinding_core_node.so | grep -i "not found"
# If any shared libs missing, the binary is incompatible
npm install Still Hangs on sharp
Symptom: npm install hangs or errors on sharp
Check:
sudo bastille cmd cms pkg info | grep vips
# Must show vips installation
Fix:
sudo bastille cmd cms pkg install -y vips
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && npm rebuild sharp"
Strapi Won't Start (Port 1337 Errors)
Symptom: Error: listen EADDRINUSE :::1337
Check:
sudo bastille cmd cms sockstat -l | grep 1337
Fix: If port is in use, either:
- Kill the stale process:
sudo bastille cmd cms pkill -f "npm run start" - Use different port in
.env:STRAPI_PORT=1338
Database Connection Fails
Symptom: Error: getaddrinfo ENOTFOUND 10.0.1.3
Check connectivity from cms jail:
sudo bastille cmd cms psql -h 10.0.1.3 -U strapi_cms -d strapi_cms -c "SELECT version();"
Fix:
- Ensure
dbjail is running:sudo bastille list - Ensure PostgreSQL is listening:
sudo bastille cmd db psql -l - Check firewall/PF rules allow 10.0.1.4 → 10.0.1.3:5432
Production Checklist
Before exposing Strapi to the internet:
- Change all JWT/salt secrets in
.env(useopenssl rand -base64 32) - Set
STRAPI_ADMIN_PATHto non-default (not/admin) - Enable HTTPS in nginx (via acme.sh or manual cert)
- Set
NODE_ENV=productionin.env - Restrict database user to
strapi_cmsrole (no superuser) - Enable nginx authentication for
/strapi/path - Set up automated PostgreSQL backups
- Monitor disk usage (Strapi can grow with media uploads)
Versions Tested
- FreeBSD: 15.0-RELEASE-p4
- Bastille: Latest (VNET jails)
- Node.js: 24.13.0
- npm: 11.0.0 (bundled with node24)
- Strapi: 5.0.0
- @swc/core: 1.15.24 (compiled from Rust nightly-2026-04-10)
- Rust toolchain: nightly-2026-04-10 (required; stable won't work — SWC uses unstable -Z flags)
- PostgreSQL: 15.x client (talks to db jail)
- nginx: 1.28.0
- libvips: 11.4.x (via
pkg install vips)
Build Decisions (April 12, 2026)
Plugin Feature Issue: Initial build (with plugin support) failed due to TLS space exhaustion in FreeBSD jails:
Error: No space available for static Thread Local Storage
Solution:
Rebuild with --no-default-features --features swc_v1 removes Wasmer plugin support
but retains all core transpilation. Binary size reduced from 26MB to ~18MB.
This is sufficient for Strapi's needs.
Build Time:
- First build (with plugins): ~1 hour
- Rebuild (without plugins): ~15 min (incremental recompile)
Critical Blocker (April 12, 2026)
Thread Local Storage (TLS) Exhaustion on FreeBSD + Node.js:
After successful Rust build and binary copy, loading @swc/core still fails:
Error: No space available for static Thread Local Storage
This occurs even with the minimal no-plugin build (19MB vs 26MB). The issue is not the binary size but how Rust binaries interact with Node.js on FreeBSD jails.
Root Cause:
- Rust nightly-2026-04-10 compiles bindings with TLS requirements
- Node.js 24.13.0's native module loader has strict TLS constraints on FreeBSD
- Bastille jails further restrict TLS allocation
- Result: binary cannot load, regardless of size
Possible Solutions (Not Yet Tested):
- Downgrade to Node.js 20.x (smaller TLS footprint)
- Compile @swc/core with explicit TLS relaxation flags (if possible)
- Use an older @swc/core version that has pre-built FreeBSD binaries
- Use Strapi without @swc/core (e.g., use esbuild instead)
- Run Strapi outside a jail (full VM instead)
Status: This blocks the current approach. The guide is complete and accurate up to the point of binary loading. Further investigation needed into Node.js TLS handling on FreeBSD.
API Token Management
How Strapi stores API tokens (Strapi 5)
Strapi stores API tokens in strapi_api_tokens using two columns:
| Column | Algorithm |
|---|---|
access_key |
HMAC-SHA512(plaintext_token, API_TOKEN_SALT) |
encrypted_key |
AES-256-GCM(plaintext_token) with key derived from SHA256(ADMIN_ENCRYPTION_KEY), format: v1:{iv_hex}:{ciphertext_hex}:{auth_tag_hex} |
Critical: The plaintext token is only returned once (via admin UI at creation time). It is never stored in the database — only the hash and encrypted copy. If the plaintext is lost, it must be regenerated.
Both ADMIN_ENCRYPTION_KEY and API_TOKEN_SALT must be set in the jail .env before any token operations.
Default token IDs
| id | name | type |
|---|---|---|
| 1 | Read Only | read-only |
| 2 | Full Access | full-access |
These are seeded automatically on first Strapi start. The plaintext values are shown once in the admin UI; save them immediately to the host .env as STRAPI_API_TOKEN.
Regenerating a token (if plaintext is lost)
Place this script at /home/clawdie/strapi/gen-token.mjs inside the cms jail (delete after use):
import crypto from 'crypto';
// Read these from the jail .env
const API_TOKEN_SALT = '<API_TOKEN_SALT>';
const ADMIN_ENCRYPTION_KEY = '<ADMIN_ENCRYPTION_KEY>';
const DB_PASSWORD = '<DATABASE_PASSWORD>';
const accessKey = crypto.randomBytes(128).toString('hex'); // 256 hex chars
// HMAC-SHA512 → access_key column
const hashedKey = crypto.createHmac('sha512', API_TOKEN_SALT).update(accessKey).digest('hex');
// AES-256-GCM → encrypted_key column
const rawKey = crypto.createHash('sha256').update(ADMIN_ENCRYPTION_KEY).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', rawKey, iv);
let encrypted = cipher.update(accessKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
const encryptedKey = `v1:${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`;
const { default: pg } = await import('./node_modules/pg/lib/index.js');
const client = new pg.Client({ host: '10.0.1.3', port: 5432, database: 'strapi_cms', user: 'strapi_cms', password: DB_PASSWORD });
await client.connect();
await client.query(
`UPDATE strapi_api_tokens SET access_key=$1, encrypted_key=$2, updated_at=NOW() WHERE id=2`,
[hashedKey, encryptedKey]
);
await client.end();
console.log('STRAPI_FULL_ACCESS_TOKEN=' + accessKey);
Run from the host:
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node gen-token.mjs"
Then restart Strapi (it caches tokens in memory — DB changes don't apply until restart):
sudo bastille cmd cms pkill -f "strapi start"
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node_modules/.bin/strapi start &"
Save the printed token to the host .env as STRAPI_API_TOKEN. Remove the script.
Verifying a token
sudo bastille cmd cms curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer <token>" \
http://localhost:1337/api/upload/files
# 200 = valid | 401 = wrong token or restart needed
Note: /api/content-type-builder/... requires an admin JWT, not an API token — don't use it for token verification.
Service management (correct commands)
# Start
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node_modules/.bin/strapi start &"
# Stop
sudo bastille cmd cms pkill -f "strapi start"
# Check process
sudo bastille cmd cms ps aux | grep strapi
# Rebuild admin UI
sudo bastille cmd cms su -m clawdie -c "cd /home/clawdie/strapi && node_modules/.bin/strapi build"
Future Improvements
- Investigate Node.js TLS options on FreeBSD jails
- Test with Node.js 20.x LTS
- Explore esbuild as alternative to SWC
- Systemd/rc.d service file for Strapi auto-start
- Automated ZFS snapshots pre/post Strapi builds
- S3-compatible object storage for media uploads
- Redis caching layer (separate jail)
References
- SWC Repository: https://github.com/swc-project/swc
- Strapi Documentation: https://docs.strapi.io/
- NAPI-RS (Rust ↔ Node binding): https://napi.rs/
- FreeBSD Bastille: https://bastille.readthedocs.io/
- PostgreSQL on FreeBSD Jails: [internal docs]