When to use this runbook: deploying Powernode to a fresh Linux host as the systemd-managed installation — backend, worker, worker-web, frontend, and reverse-proxy as native services with apt-installed PostgreSQL + Redis underneath. This is the path used by
dev.ipnode.net, theopscontrol plane, and (with--production) the first Vultr cutover before the modular self-host migration.If you want the Docker Compose path instead (legacy, deprecated by 2026-08-01), see
production-deployment.md. If you want the long-term modular self-host path via the Go agent + System modules, that's a separate runbook (see Golden Eclipse M3+).
The systemd installer (scripts/systemd/powernode-installer.sh) is the canonical single-node deployment the platform's own dev environment uses. Production-grade in terms of hardening posture (NoNewPrivileges, AmbientCapabilities for CAP_NET_BIND_SERVICE only, dedicated service users), but lightweight in terms of dependencies — no Docker Engine, no Kubernetes, no container runtime. The reverse-proxy (Traefik) is bundled as a vendored binary that wraps go-acme/lego for DNS-01 challenges, and certificates are managed end-to-end through the platform's own System::AcmeDnsCredential + Acme::CertificateManager pipeline.
| Item | Requirement |
|---|---|
| OS | Ubuntu 24.04 LTS (other Debian-likes likely work; not tested) |
| Privilege | Operator user with sudo (NOPASSWD recommended for unattended installs) |
| Network | Outbound to apt mirrors, cloud-images.ubuntu.com, RVM, NVM, Cloudflare API, Let's Encrypt; inbound on 80/443 if the proxy will face the internet directly (DNS-01 doesn't require inbound 80) |
| Disk | 20 GB minimum, 80 GB recommended (Ruby gems + frontend node_modules + Postgres data) |
| RAM | 4 GB minimum, 8 GB recommended for production workloads |
| DNS / TLS | If terminating TLS on this host: API token at the DNS provider (Cloudflare/Route53/DigitalOcean/Hetzner/Porkbun/OVH) with DNS-edit + Zone-read on the target zone(s) |
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-16 postgresql-16-pgvector postgresql-contrib \
redis-server \
build-essential libssl-dev libreadline-dev libyaml-dev libpq-dev \
libxml2-dev libxslt1-dev zlib1g-dev libffi-dev libgmp-dev libsqlite3-dev \
git curl wget rsync jq pkg-config autoconf bison \
ca-certificates gnupgPostgres and Redis come up as systemd services automatically (postgresql@16-main.service and redis-server.service). Both bind to localhost by default — leave it that way.
# RVM keys (signed install — required since 2014)
curl -sSL https://rvm.io/mpapis.asc | gpg --import -
curl -sSL https://rvm.io/pkuczynski.asc | gpg --import -
# Stable RVM
curl -sSL https://get.rvm.io | bash -s stable
# Source for current shell + install + default Ruby
source ~/.rvm/scripts/rvm
rvm install 3.2.8 --binary
rvm use 3.2.8 --defaultThe platform pins Ruby 3.2.8 (see Gemfile). RVM is preferred over rbenv/asdf because the installer's detect_rvm_path looks for ~/.rvm or /usr/local/rvm.
curl -sSL -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR=$HOME/.nvm
source $NVM_DIR/nvm.sh
nvm install 24.5.0
nvm alias default 24.5.0# Generate a random DB password (don't reuse dev's; ops/prod should have its own)
DB_PASS=$(openssl rand -hex 24)
echo "$DB_PASS" > ~/.postgres_password
chmod 600 ~/.postgres_password
# Create user + database via heredoc (avoid shell-escaping pitfalls of `psql -c`)
sudo -u postgres psql <<SQL
CREATE ROLE powernode WITH LOGIN PASSWORD '${DB_PASS}' CREATEDB;
CREATE DATABASE powernode_production OWNER powernode;
SQL
# Required extensions
sudo -u postgres psql -d powernode_production <<SQL
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
SQLGotcha #1:
psql -c "CREATE ROLE ... PASSWORD '$DB_PASS'"mangles the quoting via SSH and through bash-c. Always use a heredoc.
Either clone fresh (production-style):
git clone https://github.com/nodealchemy/powernode-platform.git /home/$USER/powernode-platform
cd /home/$USER/powernode-platform
git submodule update --init --recursiveOr rsync from a working dev host (faster for ops adjacent to dev on the LAN):
# From the dev workstation:
rsync -az --info=stats2 \
--exclude node_modules --exclude tmp/ --exclude log/ --exclude .bundle/ \
--exclude server/storage/files/ --exclude server/coverage/ \
--exclude frontend/dist/ --exclude frontend/build/ \
--exclude .env \
--exclude config/extensions_state.json \
--exclude frontend/.proxy-config-cache.json \
/path/to/powernode-platform/ target-host:/home/$USER/powernode-platform/Gotcha #16:
config/extensions_state.jsonandfrontend/.proxy-config-cache.jsonare deployment-specific state, not source code. The dev tree typically only disablestrading; ops/prod also needbusinessdisabled (gotcha #5). Likewise the proxy-cache file holds host allowlists specific to each environment. Always exclude both from rsync so deployment-local choices survive sync runs.
source ~/.rvm/scripts/rvm
cd ~/powernode-platform/server
bundle config set --local without development:test
bundle install --jobs 4
cd ~/powernode-platform/worker
bundle config set --local without development:test
bundle install --jobs 4cd ~/powernode-platform
sudo scripts/systemd/powernode-installer.sh install
# (add --production to create a dedicated system user named `powernode`)The installer:
- Detects
RVM_PATH,POWERNODE_RUBY_VERSION,NVM_DIR,NODE_VERSION,POWERNODE_BASEfrom the current shell environment + cwd. - Copies config templates from
scripts/systemd/configs/to/etc/powernode/. Skips files that already exist (idempotent re-runs are safe). - Installs systemd unit files from
scripts/systemd/units/to/etc/systemd/system/, patchingUser=andGroup=to the operator user. - Enables
powernode-backend@default,powernode-worker@default,powernode-worker-web@default,powernode-frontend@default.
Gotcha #2: the installer auto-detects the Ruby version from
rvm currentoutput. If RVM isn't sourced in the sudo environment, detection fails silently (Ruby version: not foundin the install output). The config template will still install, butPOWERNODE_RUBY_VERSIONmay stay at the template default. Patch it manually in Step 8.
Gotcha #3:
powernode-reverse-proxy@.serviceships disabled by default. The installer's "enable default service instances" loop doesn't include it. Enable it manually:sudo systemctl enable powernode-reverse-proxy@default.
The installer's template defaults are tuned for development. Production needs:
POWERNODE_BASE=/home/<operator>/powernode-platform # or /opt/powernode if --production
POWERNODE_MODE=production
RVM_PATH=/home/<operator>/.rvm
POWERNODE_RUBY_VERSION=ruby-3.2.8
POWERNODE_REGISTRY_URL=<your-internal-registry>/powernode
NVM_DIR=/home/<operator>/.nvm
NODE_VERSION=24.5.0
OLLAMA_API_ENDPOINT=<optional, leave blank to disable>
PORT=3000
HOST=0.0.0.0
RAILS_ENV=production
DATABASE_HOST=localhost
DATABASE_USER=powernode
DATABASE_NAME=powernode_production
POWERNODE_DATABASE_PASSWORD=<from ~/.postgres_password>
REDIS_URL=redis://localhost:6379/0
ACTION_CABLE_REDIS_URL=redis://localhost:6379/2
# Rails secrets — generate ALL of these fresh (openssl rand -hex 64 for SECRET_KEY_BASE)
SECRET_KEY_BASE=<128 hex chars>
JWT_SECRET_KEY=<64 hex chars> # NOTE: name is JWT_SECRET_KEY, NOT JWT_SECRET
WORKER_API_KEY=<64 hex chars>
# HS256 avoids needing JWT_PRIVATE_KEY (an RSA PEM). RS256 is the
# production-recommended algorithm, but PEM contents don't fit cleanly
# in systemd EnvironmentFile lines without escaping every newline. Set
# JWT_ALGORITHM=HS256 here, OR generate an RSA keypair and load via
# JWT_PRIVATE_KEY / JWT_PUBLIC_KEY (multi-line systemd env supported
# only via base64-encoding + decoding in the wrapper script — see
# powernode-bootstrap.sh).
JWT_ALGORITHM=HS256
# ActiveRecord encryption keys (required in production — initializer
# raises if all three are missing).
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=<32 chars>
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=<32 chars>
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=<32 chars>
# Puma sizing — tune for available RAM (each thread holds DB connections)
RAILS_MAX_THREADS=16
WEB_CONCURRENCY=2
DB_POOL=24
# jemalloc — reduces memory fragmentation under long-lived Rails processes
LD_PRELOAD=/lib/x86_64-linux-gnu/libjemalloc.so.2
MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
RAILS_LOG_TO_STDOUT=true
# Disable libvirt unless this host has libvirtd locally + kvm group access
POWERNODE_LIBVIRT_MODE=disabled
POWERNODE_OCI_REGISTRY=<your-internal-registry>
POWERNODE_PLATFORM_URL=http://localhost:3000
WORKER_ENV=production
REDIS_URL=redis://localhost:6379/1
WORKER_CONCURRENCY=10
# WORKER_ID — populated AFTER db:seed (see step 10)
WORKER_ID=
PRIMARY_SERVICE_URL=http://localhost:3000
BACKEND_API_URL=http://localhost:3000
# Must match backend's JWT_SECRET_KEY (this is the bidirectional auth secret)
JWT_SECRET_KEY=<same value as backend's JWT_SECRET_KEY>
# Same AR encryption keys as backend (worker reads the same DB)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=<same>
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=<same>
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=<same>
DATABASE_HOST=localhost
DATABASE_USER=powernode
DATABASE_NAME=powernode_production
POWERNODE_DATABASE_PASSWORD=<same as backend>
PORT=3001
HOST=0.0.0.0
VITE_API_BASE_URL=https://<external-hostname>
VITE_WS_BASE_URL=wss://<external-hostname>
NODE_ENV=production
After patching, reapply restrictive permissions:
sudo chown root:<operator-group> /etc/powernode/*.conf
sudo chmod 640 /etc/powernode/*.confsource ~/.rvm/scripts/rvm
cd ~/powernode-platform/server
# Load all env (powernode.conf + backend-default.conf) into the shell so rails
# sees the secrets + connection strings.
set -a
. /etc/powernode/powernode.conf
. /etc/powernode/backend-default.conf
set +a
bundle exec rails db:create db:migrate db:seedGotcha #4:
db:seedonly creates the admin Account/User indevelopmentortestmode by default. In production it skips the user seed entirely (look forif Rails.env.development? || Rails.env.test? || ENV['SEED_ADMIN_USERS'] == 'true'indb/seeds.rb), thensystem_workercreation immediately after FAILS withAccount can't be blank, Account must existbecause there's no Account for it to belong to. Fix: setSEED_ADMIN_USERS=truein the environment for the initial seed run on a fresh production install. This loadsdb/seeds/cypress_test_users.rbwhich writes admin credentials totest-credentials.json— review and rotate the admin password before exposing ops publicly.
Gotcha #5: The business extension's
Ai::Marketplace::InstallationServicereferences anInstallWorkflowconcern that doesn't exist in the repo (only 2 of 3 expected concern files are present:rating_and_serialization.rbandupdate_and_uninstall.rb— but NOTinstall_workflow.rb). Dev mode doesn't notice because Zeitwerk lazy-loads; production hitseager_load_allat boot which surfaces the missing constant. Fix: for ops/core-mode single-node deploys that don't need SaaS billing features, add"business"toconfig/extensions_state.json'sdisabledlist. The platform falls back to core mode automatically viaShared::FeatureGateService.
Gotcha #6: Every extension engine adds
app/decorators/to autoload_paths AND usesconfig.to_preparetoloadthe files explicitly. The decorator files useAccount.class_eval do ... endand don't define their own constant — but Zeitwerk'seager_load_allin production still scans the dir and raisesZeitwerk::NameError. Fix (already in master after the ops bootstrap): each engine.rb has aninitializer "*.ignore_decorators"block callingRails.autoloaders.main.ignore(decorators_path)to tell Zeitwerk to skip the dir. The to_prepare load still runs decorators via path-basedload. Five engines patched: system, business, marketing, supply-chain, trading.
Gotcha #7:
Ai::CodeReviewwas BOTH an ActiveRecord model class (app/models/ai/code_review.rb→class CodeReview) AND a services namespace (app/services/ai/code_review/*.rb→module Ai; module CodeReview). A Ruby constant can't be both. Fix: rename services dir to pluralcode_reviews/and update module declarations inside tomodule CodeReviews. Matches the platform's existing convention of singular-model-class + plural-services-namespace (e.g.Ai::Agentmodel +app/services/ai/agents/).
Gotcha #8:
app/services/mcp/workflow_executor.rbis dead code — its 7 concern files (broadcasting.rb,data_flow.rb, etc.) plusconcerns/ai_workflow_service.rbwere deleted in commita61ca9ef"remove ai workflow services" but the orchestrator file itself was missed. Fix: delete it (no remaining references).
Gotcha #9:
app/controllers/api/v1/ai/intelligence/baas_controller.rbdefinesclass BaasControllerbut with the BaaS acronym ininflections.rb, Zeitwerk expectsclass BaaSController(matching the BaaS model namespace atapp/models/baas.rb). Fix: rename the class declaration toBaaSController.
Gotcha #10: Rails 8.1 includes the
solid_cache,solid_queue, andsolid_cablegems which auto-loadSolidCache::Record,SolidQueue::Record,SolidCable::Recordat boot — each callsconnects_to(database: { writing: :cache | :queue | :cable }). Even if you overrideCACHE_STORE=redis_cache_store,QUEUE_ADAPTER=async, andcable.ymlto use Redis, the gems still get required and theirRecordclasses still demand the named databases exist indatabase.yml. Fix: extenddatabase.ymlproduction:block to multi-database with stubs forprimary,cache,queue,cableall pointing to the same Postgres database. The Solid* gems will connect but won't actually be used because the env overrides keep them out of the request path. Future cleanup:gem ... require: falsewould let us drop the database stubs entirely.
Gotcha #11: The server's
Gemfiledoes NOT includesidekiq(the worker process has it separately). SettingQUEUE_ADAPTER=sidekiqinbackend-default.confcausesGem::LoadError: sidekiq is not part of the bundle. Fix: useQUEUE_ADAPTER=asyncfor the server. The server enqueues jobs in-process; the worker service polls them via the HTTP API.
Gotcha #12: Frontend systemd service fails with
sh: 1: vite: not foundbecause the rsync'd repo doesn't includefrontend/node_modules/. Fix:cd frontend && npm installbefore starting the service.
Gotcha #13: The
powernode-reverse-proxy@.serviceunit file hasUser=retthardcoded in the dev-tree version of the unit. The installer DOES patch User= via sed, but only one occurrence. Fix:sudo sed -i 's/^User=.*/User=<operator>/; s/^Group=.*/Group=<operator>/' /etc/systemd/system/powernode-reverse-proxy@.serviceto fix both lines, thensystemctl daemon-reload.
Gotcha #14: The reverse-proxy systemd unit only loads
powernode.conf(NOTbackend-default.conf). The wrapper script callsbundle exec rails runnerto regenerate Traefik static config — but Rails boot needsRAILS_ENV,ACTIVE_RECORD_ENCRYPTION_*,SECRET_KEY_BASE,JWT_SECRET_KEY, DB credentials. Fix: promote these shared secrets to/etc/powernode/powernode.conf(the file ALL services load). Don't duplicate-only-in-backend-default.conf.
Gotcha #15: Even with the env right, the wrapper script fails because Rails 8.1 initializers emit chatter to stdout BEFORE the
print Acme::TraefikConfigWriter.write_static_config!line — "ActiveRecord Encryption configured", "[Sentry] Skipped - SENTRY_DSN not configured", "Initializing billing automation system", "Billing automation system initialized successfully", "JWT configured with algorithm: HS256", "Vault not configured in production - credentials stored in database". The script'sSTATIC_CONFIG="$(bundle exec rails runner '...')"captures all of this as a multi-line string, then[[ ! -f "$STATIC_CONFIG" ]]fails because the multi-line string isn't a valid file path. Fix (already in master): the wrapper script pipes through| tail -n 1to grab only the final non-newlined line which is the path itself (puts, avoids a trailing newline so it stays the last line).
cd ~/powernode-platform/server
WORKER_ID=$(bundle exec rails runner 'puts Worker.system_worker.id' 2>/dev/null | tail -1)
sudo sed -i "s|^WORKER_ID=.*|WORKER_ID=${WORKER_ID}|" \
/etc/powernode/worker-default.conf /etc/powernode/worker-web-default.confsudo systemctl daemon-reload
sudo systemctl start powernode.target
sleep 5
sudo scripts/systemd/powernode-installer.sh statusAll five services should be active:
powernode-backend@defaultpowernode-worker@defaultpowernode-worker-web@defaultpowernode-frontend@defaultpowernode-reverse-proxy@default
Smoke test:
curl -sI http://localhost:3000/health # → HTTP/1.1 200
curl -sI http://localhost:3001 # → HTTP 200 (frontend dev server)The platform stores DNS provider credentials in the database via System::AcmeDnsCredential (model: extensions/system/server/app/models/system/acme_dns_credential.rb). The actual API token is stored in Vault (or, in the absence of Vault, the database fallback path). The reverse-proxy systemd service consumes the resulting certs via Acme::TraefikConfigWriter.
Setup (Rails console):
cred = System::AcmeDnsCredential.create!(
account: Account.first,
name: "powernode-cloudflare", # match dev's naming if applicable
provider: "cloudflare", # or route53 / digitalocean / hetzner / porkbun / ovh
status: "untested"
)
cred.store_in_vault(api_token: "<your-CF-token>")If migrating from a working dev environment:
# On the dev host:
src = System::AcmeDnsCredential.where(provider: "cloudflare").first
token = src.vault_credentials[:api_token]
# Then on the new host (via rails runner):
dest = System::AcmeDnsCredential.create!(account: Account.first, name: src.name, provider: src.provider)
dest.store_in_vault(api_token: token)Trigger first cert issuance:
mgr = Acme::CertificateManager.new
mgr.issue!(common_name: "ops.ipnode.net", dns_credential: cred, email: "admin@example.com")Acme::RenewalSweepService (background job, runs daily) handles renewals from here on.
curl -vk https://<external-hostname> 2>&1 | grep -E "(subject|issuer|HTTP)"
openssl s_client -connect <external-hostname>:443 -servername <external-hostname> </dev/null 2>&1 | grep -E "(subject=|issuer=)"- All 5 systemd services active (
systemctl status 'powernode-*' --no-pager) -
curl http://localhost:3000/healthreturns 200 with{status: "ok"}JSON -
psql -U powernode -h localhost -d powernode_production -c 'SELECT 1'succeeds with the DB password - Sidekiq web UI reachable at
http://localhost:4567(worker-web) -
bundle exec rails runner 'puts Worker.system_worker.account_id'returns a non-nil UUID -
Acme::CertificateManager.new.list_certs.any? { |c| c.status == "active" }is true (after Step 12 issuance) - External HTTPS endpoint serves a Let's Encrypt cert (not self-signed, not Cloudflare edge)
| Error | Cause | Fix |
|---|---|---|
ActiveRecord encryption primary_key not configured |
Production env requires explicit AR encryption keys | Set the three ACTIVE_RECORD_ENCRYPTION_* env vars in backend-default.conf AND worker-default.conf/worker-web-default.conf |
JWT_SECRET_KEY environment variable is required in production |
The env var name is JWT_SECRET_KEY (not JWT_SECRET or JWT_TOKEN) |
Rename in backend-default.conf |
JWT_PRIVATE_KEY environment variable is required for RSA signing |
Default JWT_ALGORITHM=RS256 in production needs an RSA PEM |
Set JWT_ALGORITHM=HS256 to use the existing HMAC secret, OR generate an RSA keypair (see powernode-bootstrap.sh) |
Failed to create system worker: Validation failed: Account can't be blank |
Production db:seed doesn't create admin Account by default (see gotcha #4) |
Set SEED_ADMIN_USERS=true env var and re-seed |
uninitialized constant Ai::Marketplace::InstallationService::InstallWorkflow |
Business extension is referenced but install_workflow.rb concern file is missing (gotcha #5) |
Add "business" to disabled list in config/extensions_state.json for core-mode deploys |
Zeitwerk::NameError: expected file .../decorators/models/account_decorator.rb to define constant Models::AccountDecorator |
Extension engines add app/decorators to autoload_paths but the files monkey-patch core (no own constant) |
Already fixed in master (gotcha #6); each engine has Rails.autoloaders.main.ignore(decorators_path) |
CodeReview is not a module (TypeError) |
Ai::CodeReview was both model class and services namespace (gotcha #7) |
Already fixed in master; services renamed to Ai::CodeReviews::* |
uninitialized constant Mcp::WorkflowExecutor::AiWorkflowService |
Dead code (gotcha #8) | Already fixed in master; orphan file deleted |
Zeitwerk::NameError: expected ... BaaSController, but didn't |
Controller defined BaasController; with the BaaS acronym Zeitwerk expects BaaSController (gotcha #9) |
Already fixed in master |
The 'cache'/'queue'/'cable' database is not configured for the 'production' environment |
Solid* gems demand named DBs even when env overrides disable them (gotcha #10) | Already fixed in master; database.yml production has multi-db stubs |
Gem::LoadError: sidekiq is not part of the bundle |
Server doesn't bundle sidekiq directly (gotcha #11) | Set QUEUE_ADAPTER=async in backend-default.conf |
Frontend service: sh: 1: vite: not found |
npm packages not installed (gotcha #12) | cd frontend && npm install before starting the service |
Reverse-proxy: Failed to determine user credentials: No such process; status=217/USER |
The User=rett line in the dev-tree unit file wasn't patched by installer's sed (gotcha #13) |
sudo sed -i 's/^User=.*/User=<operator>/; s/^Group=.*/Group=<operator>/' /etc/systemd/system/powernode-reverse-proxy@.service && systemctl daemon-reload |
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: database "..." does not exist |
Shell escaping mangled the CREATE DATABASE statement | Use a heredoc, not psql -c "..." |
RVM not found. Set RVM_PATH in /etc/powernode/powernode.conf |
The reverse-proxy/backend wrapper script can't find RVM | Set RVM_PATH=/home/<operator>/.rvm explicitly in /etc/powernode/powernode.conf |
Reverse proxy service stays disabled |
The installer only enables backend/worker/worker-web/frontend by default | sudo systemctl enable powernode-reverse-proxy@default |
MTU mismatch warnings in journald |
Service listens on jumbo-frame interface but talks to local services on lo (MTU 65536) | Usually harmless; verify by checking ss -tlnp lists the right binds |
A standardized wrapper around the above steps lives at scripts/systemd/powernode-bootstrap.sh. It's idempotent (safe to re-run) and exposes the gotcha-prone steps as named subcommands:
sudo scripts/systemd/powernode-bootstrap.sh deps # Step 1: apt
sudo scripts/systemd/powernode-bootstrap.sh ruby # Step 2: RVM + Ruby (runs as operator)
sudo scripts/systemd/powernode-bootstrap.sh node # Step 3: nvm + Node
sudo scripts/systemd/powernode-bootstrap.sh postgres # Step 4: DB user + db + extensions
sudo scripts/systemd/powernode-bootstrap.sh secrets # Step 8: generate + inject secrets
sudo scripts/systemd/powernode-bootstrap.sh dbinit # Step 9: db:create + migrate + seed
sudo scripts/systemd/powernode-bootstrap.sh workerid # Step 10: populate WORKER_ID
sudo scripts/systemd/powernode-bootstrap.sh start # Step 11: systemctl start powernode.target
sudo scripts/systemd/powernode-bootstrap.sh all # everything in orderACME setup (Step 12) is intentionally kept manual — choosing the right DNS provider + storing the API token securely warrants per-deployment thought.
- Vault integration: Step 12 currently uses database fallback for credentials. Production deployments should deploy Vault first (
docs/infrastructure/vault-example/) and haveAcme::DnsCredential.store_in_vaultwrite to a real Vault. - RS256 JWT in production: HS256 is acceptable but RS256 is preferred for any deployment where the JWT could be inspected by an untrusted party. Generate the RSA pair in
powernode-bootstrap.sh secrets, base64-encode the PEM, and decode in the backend wrapper script. - Modular self-host migration: The systemd installer path is canonical for now but slated to be replaced by the Go agent + System modules path under Golden Eclipse M3+. This runbook should be marked deprecated and a
single-node-modular-bootstrap.mdwritten when that landed.