Overview
Platform ownershipAdmins own the full runtime and commercial configuration of the platform. That includes install decisions, billing credentials, plan design, tenant placement, infrastructure pools, runtime health, and incident response.
- Core setup controls branding, billing credentials, landing-page content, AI provider keys, and docs visibility.
- SaaSForge controls tenants, domains, provisioning jobs, provisioning servers, health checks, backups, runtime dependencies.
- N8NForge controls managed instances, launch metadata, and workflow-template delivery.
Server setup
VPS preparationBefore installing N8Forge, the target server must have a working LAMP/LEMP stack with PHP, a database, a reverse proxy for TLS termination, Docker for n8n runtimes, plus supporting services for queues, caching, and background work. This section guides you through preparing a fresh Ubuntu 24.04 server. Adjust package names if you use Debian or a different version.
Apache (HTTPD) serves the N8Forge application on loopback. Caddy terminates TLS on ports 80/443 and reverse-proxies to Apache. Caddy also manages per-tenant site configs for provisioned n8n instances.
Required extensions: BCMath, Ctype, Fileinfo, JSON, Mbstring, OpenSSL, PDO, Tokenizer,
XML, Curl, GD, Intl, Zip, MySQL (pdo_mysql), Redis, exif, gmp. The pcntl
and posix extensions are compiled into PHP CLI by default — no
separate packages needed.
MySQL 8.0+ or MariaDB 10.11+ for the main application database. PostgreSQL can also be used but is not covered in this guide.
Docker Engine and Compose plugin for running n8n runtime containers provisioned by
SaaSForge. The deploy user must be in the docker group.
Redis for Laravel queues, sessions, and cache. Supervisor runs the queue worker process persistently.
Composer (PHP dependency manager), Node.js 20+ and npm (asset compilation), Git (version control), cron (scheduler), UFW (firewall).
The N8Forge app server runs two web-facing services on the same host:
- Caddy listens on ports 80 and 443 (TLS). It reverse-proxies the main N8Forge domain to Apache and also manages dynamic per-tenant site files for n8n instances (written by the SaaSForge provisioning system).
- Apache listens on
127.0.0.1:8080and serves the N8Forge Laravel application via PHP-FPM. It is never exposed directly to the internet. - Docker runs n8n containers on allocated host ports (default range 23000+). Caddy reverse-proxies tenant subdomains to these ports.
This separation keeps TLS termination, tenant routing, and application serving each in their own layer.
When Caddy is configured as shown below, the same Caddy instance reverse-proxies the
main app domain and imports per-tenant site configs from the
sites-enabled/saasforge/ directory. SaaSForge writes tenant site files
into that directory and reloads Caddy automatically during provisioning.
The following script prepares a fresh Ubuntu 24.04 server. Run it as root or with a sudo-enabled user. It installs every service listed above, applies hardening defaults, and prints a summary at the end.
#!/usr/bin/env bash
set -euo pipefail
###############################################################################
# N8Forge — install-server bootstrap script
#
# Prepares a fresh Ubuntu 24.04 VPS with:
# PHP 8.3 + extensions, MySQL 8, Apache (loopback :8080), Caddy (80/443),
# Docker Engine + Compose plugin, Redis, Supervisor, Composer, Node.js.
#
# Usage:
# sudo bash bootstrap-n8forge.sh
#
# After the script finishes, review the summary, then proceed to the
# N8Forge installation section.
###############################################################################
# ── Config ─────────────────────────────────────────────────────────────────
APP_DOMAIN="${APP_DOMAIN:-app.example.com}"
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
MYSQL_N8FORGE_DATABASE="${MYSQL_N8FORGE_DATABASE:-n8forge}"
MYSQL_N8FORGE_USER="${MYSQL_N8FORGE_USER:-n8forge}"
MYSQL_N8FORGE_PASSWORD="${MYSQL_N8FORGE_PASSWORD:-}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
PHP_VERSION="${PHP_VERSION:-8.3}"
TIMEZONE="${TIMEZONE:-UTC}"
# Colors for output
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
info(){ echo -e "${YELLOW}[..]${NC} $1"; }
fail(){ echo -e "${RED}[FAIL]${NC} $1"; exit 1; }
# ── Pre-flight checks ──────────────────────────────────────────────────────
[[ $EUID -eq 0 ]] || fail "Run this script as root (or with sudo)."
command -v apt-get &>/dev/null || fail "This script requires apt (Debian/Ubuntu)."
# Detect Ubuntu version
. /etc/os-release
UBUNTU_MAJOR="${VERSION_ID%%.*}"
[[ "$UBUNTU_MAJOR" -ge 22 ]] || fail "Ubuntu 22.04+ required (detected $VERSION_ID)."
# ── 1. System updates & base packages ──────────────────────────────────────
info "Updating system packages ..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get upgrade -y -qq
apt-get install -y -qq \
curl wget gnupg2 ca-certificates lsb-release software-properties-common \
ufw fail2ban unzip git cron
# Timezone
timedatectl set-timezone "$TIMEZONE"
# Swap (if none exists)
if ! swapon --show | grep -q .; then
info "Creating 2 GB swap file ..."
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
ok "Swap created."
fi
ok "System packages installed."
# ── 2. PHP ─────────────────────────────────────────────────────────────────
info "Installing PHP $PHP_VERSION and extensions ..."
add-apt-repository -y ppa:ondrej/php
apt-get update -qq
apt-get install -y -qq \
php${PHP_VERSION} php${PHP_VERSION}-fpm \
php${PHP_VERSION}-bcmath php${PHP_VERSION}-ctype php${PHP_VERSION}-curl \
php${PHP_VERSION}-dom php${PHP_VERSION}-fileinfo php${PHP_VERSION}-gd \
php${PHP_VERSION}-gmp php${PHP_VERSION}-intl php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-mysql php${PHP_VERSION}-opcache \
php${PHP_VERSION}-redis php${PHP_VERSION}-xml \
php${PHP_VERSION}-zip php${PHP_VERSION}-exif
# Tune PHP-FPM for a production Laravel app
sed -i 's/^pm.max_children =.*/pm.max_children = 50/' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf
sed -i 's/^pm.start_servers =.*/pm.start_servers = 8/' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf
sed -i 's/^pm.min_spare_servers =.*/pm.min_spare_servers = 4/' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf
sed -i 's/^pm.max_spare_servers =.*/pm.max_spare_servers = 16/' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf
systemctl enable php${PHP_VERSION}-fpm
ok "PHP $PHP_VERSION installed."
# ── 3. MySQL ───────────────────────────────────────────────────────────────
info "Installing MySQL 8 ..."
# Pre-seed root password if provided (interactive install otherwise)
if [[ -n "$MYSQL_ROOT_PASSWORD" ]]; then
debconf-set-selections <<< "mysql-server mysql-server/root_password password $MYSQL_ROOT_PASSWORD"
debconf-set-selections <<< "mysql-server mysql-server/root_password_again password $MYSQL_ROOT_PASSWORD"
fi
apt-get install -y -qq mysql-server
systemctl enable mysql
systemctl start mysql
# Create application database and user
if [[ -z "$MYSQL_N8FORGE_PASSWORD" ]]; then
MYSQL_N8FORGE_PASSWORD="$(openssl rand -base64 24)"
echo -e "\nGenerated MySQL password for '${MYSQL_N8FORGE_USER}': ${MYSQL_N8FORGE_PASSWORD}\n" >> /root/.mysql_credentials
fi
mysql <<SQL
CREATE DATABASE IF NOT EXISTS \`${MYSQL_N8FORGE_DATABASE}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${MYSQL_N8FORGE_USER}'@'localhost' IDENTIFIED BY '${MYSQL_N8FORGE_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${MYSQL_N8FORGE_DATABASE}\`.* TO '${MYSQL_N8FORGE_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
# Secure the installation
mysql --execute="DELETE FROM mysql.user WHERE User=''; DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1'); DROP DATABASE IF EXISTS test; DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%'; FLUSH PRIVILEGES;" || true
ok "MySQL 8 installed. DB '${MYSQL_N8FORGE_DATABASE}' created for user '${MYSQL_N8FORGE_USER}'."
# ── 4. Apache ──────────────────────────────────────────────────────────────
info "Installing Apache with PHP-FPM proxy ..."
apt-get install -y -qq apache2 libapache2-mod-fcgid
a2enmod proxy_fcgi setenvif rewrite
a2enconf php${PHP_VERSION}-fpm
# Listen only on loopback
sed -i 's/^Listen 80$/Listen 127.0.0.1:8080/' /etc/apache2/ports.conf
# Create the N8Forge vhost
cat > /etc/apache2/sites-available/n8forge.conf <<'APACHE'
<VirtualHost 127.0.0.1:8080>
ServerName _default_
DocumentRoot /var/www/n8forge/public
<Directory /var/www/n8forge/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
</FilesMatch>
ErrorLog ${APACHE_LOG_DIR}/n8forge-error.log
CustomLog ${APACHE_LOG_DIR}/n8forge-access.log combined
</VirtualHost>
APACHE
a2dissite 000-default
a2ensite n8forge
systemctl enable apache2
ok "Apache configured on 127.0.0.1:8080."
# ── 5. Caddy (reverse proxy for the app + tenant domains) ──────────────────
info "Installing Caddy ..."
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq
apt-get install -y -qq caddy
# Create the SaaSForge tenant site directory
mkdir -p /etc/caddy/sites-enabled/saasforge
# Write the main Caddyfile
cat > /etc/caddy/Caddyfile <<CADDY
{
email admin@${APP_DOMAIN#*.}
on_demand_tls {
ask https://${APP_DOMAIN}/saasforge/caddy/on-demand-tls
burst 5
}
}
# Main N8Forge app — reverse-proxy to Apache
${APP_DOMAIN} {
tls {
on_demand
}
encode gzip
reverse_proxy 127.0.0.1:8080 {
# Pass the real client IP (Caddy is at the edge, not behind another proxy)
header_up X-Real-IP {remote_host}
#header_up X-Forwarded-For {remote_host}
}
}
import /etc/caddy/sites-enabled/saasforge/*
CADDY
systemctl enable caddy
ok "Caddy installed. App domain: ${APP_DOMAIN}"
# ── 6. Docker ──────────────────────────────────────────────────────────────
info "Installing Docker Engine ..."
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
apt-get remove -y "$pkg" 2>/dev/null || true
done
curl -fsSL https://get.docker.com | bash
# Docker daemon tuning
cat > /etc/docker/daemon.json <<'DOCKER'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true,
"storage-driver": "overlay2"
}
DOCKER
systemctl enable docker
systemctl restart docker
ok "Docker Engine installed."
# ── 7. Create deploy user ──────────────────────────────────────────────────
info "Creating deploy user '${DEPLOY_USER}' ..."
id -u "$DEPLOY_USER" &>/dev/null || useradd -m -s /bin/bash "$DEPLOY_USER"
usermod -aG docker "$DEPLOY_USER"
# Create runtime directories
mkdir -p /var/lib/saasforge/runtime
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" /var/lib/saasforge/runtime
# Create the app directory (content placed later)
mkdir -p /var/www/n8forge
chown "${DEPLOY_USER}:www-data" /var/www/n8forge
ok "Deploy user '${DEPLOY_USER}' created."
# ── 8. Composer & Node.js ──────────────────────────────────────────────────
info "Installing Composer ..."
EXPECTED_CHECKSUM="$(php -r 'copy("https://composer.github.io/installer.sig", "php://stdout");')"
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
echo 'ERROR: Composer installer checksum mismatch' >&2
rm composer-setup.php
exit 1
fi
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
ok "Composer installed."
info "Installing Node.js 20 LTS ..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y -qq nodejs
ok "Node.js $(node --version) installed."
# ── 9. Redis ───────────────────────────────────────────────────────────────
info "Installing Redis ..."
apt-get install -y -qq redis-server
sed -i 's/^supervised no/supervised systemd/' /etc/redis/redis.conf
systemctl enable redis-server
systemctl restart redis-server
ok "Redis installed."
# ── 10. Supervisor (queue worker) ──────────────────────────────────────────
info "Configuring Supervisor for queue worker ..."
apt-get install -y -qq supervisor
cat > /etc/supervisor/conf.d/n8forge-worker.conf <<SUPERVISOR
[program:n8forge-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/n8forge/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --timeout=600
directory=/var/www/n8forge
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=${DEPLOY_USER}
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/n8forge/storage/logs/worker.log
stdout_logfile_maxbytes=10MB
SUPERVISOR
systemctl enable supervisor
ok "Supervisor configured for queue worker."
# ── 11. Firewall ───────────────────────────────────────────────────────────
info "Configuring UFW firewall ..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ok "UFW active — SSH, HTTP, HTTPS allowed."
# ── 12. Kernel tuning ──────────────────────────────────────────────────────
info "Applying kernel tuning ..."
cat > /etc/sysctl.d/99-n8forge.conf <<'SYSCTL'
fs.file-max = 100000
vm.max_map_count = 262144
net.core.somaxconn = 1024
SYSCTL
sysctl --system
# ── 13. Cron (Laravel scheduler) ───────────────────────────────────────────
info "Setting up Laravel scheduler cron job ..."
cat > /etc/cron.d/n8forge-scheduler <<CRON
* * * * * ${DEPLOY_USER} cd /var/www/n8forge && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
CRON
chmod 0644 /etc/cron.d/n8forge-scheduler
# ── Summary ────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} N8Forge — VPS bootstrap complete${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo " App directory : /var/www/n8forge"
echo " App domain : ${APP_DOMAIN}"
echo ""
echo " Database : ${MYSQL_N8FORGE_DATABASE}"
echo " DB user : ${MYSQL_N8FORGE_USER}"
echo " DB password : ${MYSQL_N8FORGE_PASSWORD:-<set via MYSQL_N8FORGE_PASSWORD>}"
echo " DB host : localhost"
echo ""
echo " Apache (loopback) : http://127.0.0.1:8080"
echo " Caddy (public) : https://${APP_DOMAIN}"
echo ""
echo " Deploy user : ${DEPLOY_USER}"
echo " Runtime root : /var/lib/saasforge/runtime"
echo ""
echo " Services enabled:"
echo " - php${PHP_VERSION}-fpm : Active"
echo " - mysql : Active"
echo " - apache2 : Active"
echo " - caddy : Active"
echo " - docker : Active"
echo " - redis-server : Active"
echo " - supervisor : Active"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Point DNS for ${APP_DOMAIN} to this server's public IP."
echo " 2. Upload the N8Forge release ZIP into /var/www/n8forge."
echo " 3. Set correct ownership: chown -R \${DEPLOY_USER}:www-data /var/www/n8forge"
echo " 4. Copy and edit the .env file with the database credentials above."
echo " 5. Open https://${APP_DOMAIN}/install in your browser."
echo " 6. Complete the web installer, then proceed to the First-day checklist."
echo ""
echo -e "${RED}Save the database password above. It will not be shown again.${NC}"
echo ""
Save the script as bootstrap-n8forge.sh, make it executable
(chmod +x bootstrap-n8forge.sh), and run it as root:
sudo bash bootstrap-n8forge.sh. You can pre-set the app domain,
database name, and deploy user by exporting environment variables before running:
export APP_DOMAIN=n8forge.yourcompany.com
export MYSQL_N8FORGE_PASSWORD="$(openssl rand -base64 24)"
export DEPLOY_USER=n8forge
sudo -E bash bootstrap-n8forge.sh
Point your domain
Create an A record for ${APP_DOMAIN} pointing at the server's
public IP address. If you use a subdomain of a delegated zone (e.g.,
*.n8forge.example.com), ensure the wildcard or individual
tenant records also resolve to this server.
Upload the release
Upload the N8Forge release ZIP into /var/www/n8forge and
extract it. Set ownership so the deploy user owns the files and the
web server group can read them:
chown -R n8forge:www-data /var/www/n8forge.
Set writable paths
Ensure storage/ and bootstrap/cache/ are writable
by the web server group. Run:
chmod -R 775 storage bootstrap/cache && chmod g+s storage bootstrap/cache.
The g+s flag ensures new files and directories created by
the web server inherit the www-data group.
Installation
First deploy- PHP 8.3+ with the Laravel and Wave requirements expected by the packaged app.
- Application database on MySQL, MariaDB, PostgreSQL, or SQLite.
- Queue and scheduler support because provisioning, templates, cleanup, and reminders depend on scheduled work.
- Docker-capable runtime hosts for the local Linux-host driver or any SSH provisioning target.
Extract the main package
Deploy the versioned install ZIP into the target app directory and point the web root
at public/.
Prepare writable paths
Ensure Laravel can write to storage/ and
bootstrap/cache/, then provide a valid .env.
Run the installer
Open /install, finish the environment and database steps, then create
the
first admin account.
Log in and complete product setup
Move immediately into Setup, Plans, Themes, and provisioning resources before testing real customer flows.
First-day checklist
Recommended order- Configure Setup for General, Billing, Landing Page, AI, and Authentication.
- Review plans and assign real billing price IDs plus any plan-level delivery mappings.
- Choose the public theme and replace seeded marketing content.
- Enable scheduler execution so provisioning and health automation can run.
- Create provisioning resources such as SSH servers, external databases, and external storage backends if you will not rely only on the default local runtime path.
- Run a real test subscription and confirm it creates a tenant, queues a job, and produces a usable runtime instance page. or custom domains.
Setup page
Shared admin configThe Core Setup page is the main live configuration surface for branding, billing credentials, marketing content, AI provider keys, and authentication presentation.
Site title, description, logos, icons, analytics, and docs visibility in app menus.
Stripe or Paddle provider selection plus encrypted credential and webhook storage.
Hero, CTA, stats, pricing copy, testimonials, and public marketing sections.
Encrypted provider keys for OpenAI, Anthropic, Grok, and DeepSeek.
Shared auth-related setup exposed through the same admin flow.
Database-backed setup values are loaded into runtime config and become the live source of truth.
Key env reference
Operator-level settings| Variable | Default | Purpose |
|---|---|---|
| SaaSForge — Base | ||
SAASFORGE_WORKSPACE_SUFFIX |
Workspace |
Suffix appended to tenant workspace names |
SAASFORGE_PROVISIONING_DRIVER |
docker |
Default runtime driver (docker / ssh) |
SAASFORGE_PROVISIONING_QUEUE |
default |
Queue connection for provisioning jobs |
SAASFORGE_PROVISIONING_BATCH_SIZE |
10 |
Max jobs processed per scheduler run |
SAASFORGE_SHOW_CADDY_FIX_BUTTONS |
false |
Show admin UI buttons for Caddy repair |
SAASFORGE_BASE_DOMAIN |
app.url host | Base domain for generated tenant subdomains |
SAASFORGE_AUTO_SSL |
true |
Auto-provision SSL via Let's Encrypt |
SAASFORGE_PRODUCT_TYPE |
n8nforge |
Default product type for new tenants |
SAASFORGE_AUTO_CREATE_FIRST_INSTANCE |
true |
Auto-create the first n8n instance when a tenant is created from a subscription |
| SaaSForge — Docker Runtime | ||
SAASFORGE_LINUX_RUNTIME_ENABLED |
true |
Enable the local Docker driver |
SAASFORGE_RUNTIME_ROOT_PATH |
storage/.../runtime |
Host path for compose files and runtime data |
SAASFORGE_DOCKER_BIN |
docker |
Docker binary path |
SAASFORGE_DOCKER_COMPOSE_COMMAND |
docker compose |
Compose command |
SAASFORGE_RUNTIME_NETWORK |
saasforge |
Docker network name for runtime containers |
SAASFORGE_RUNTIME_IMAGE |
docker.n8n.io/n8nio/n8n:latest |
n8n container image tag |
SAASFORGE_RUNTIME_BASE_PORT |
23000 |
Starting port for container port mapping |
SAASFORGE_RUNTIME_PORT_STEP |
1 |
Port increment between instances |
SAASFORGE_RUNTIME_WAIT_TIMEOUT |
180 |
Seconds to wait for container health |
SAASFORGE_RUNTIME_TIMEZONE |
UTC |
Timezone set on runtime containers |
SAASFORGE_RUNTIME_CPUS |
0.5 |
Default CPU limit per container |
SAASFORGE_RUNTIME_MEMORY |
600m |
Default memory limit per container |
SAASFORGE_RUNTIME_MEMORY_SWAP |
700m |
Default swap limit per container |
SAASFORGE_RUNTIME_RUNNERS_ENABLED |
true |
Enable n8n task runners |
| SaaSForge — Caddy (Docker) | ||
SAASFORGE_RUNTIME_CADDY_ENABLED |
true |
Manage Caddy reverse-proxy sites |
SAASFORGE_RUNTIME_CADDY_BIN |
caddy |
Caddy binary path |
SAASFORGE_RUNTIME_CADDY_RELOAD |
true |
Run caddy reload after site changes |
SAASFORGE_RUNTIME_CADDY_MAIN_CONFIG |
/etc/caddy/Caddyfile |
Main Caddyfile path |
SAASFORGE_RUNTIME_CADDY_SITE_DIR |
storage/.../caddy-sites |
Directory for per-instance site files |
SAASFORGE_RUNTIME_CADDY_TLS_EMAIL |
MAIL_FROM_ADDRESS | Email for Let's Encrypt certificate notices |
| SaaSForge — SSO / OIDC | ||
SAASFORGE_RUNTIME_N8N_SSO_ENABLED |
false |
Enable OIDC SSO for instance login |
SAASFORGE_RUNTIME_N8N_SSO_PROTOCOL |
oidc |
SSO protocol (oidc) |
SAASFORGE_RUNTIME_N8N_SSO_MANAGED_BY_ENV |
true |
Inject SSO config via container env vars |
SAASFORGE_RUNTIME_N8N_SSO_USER_ROLE_PROVISIONING |
disabled |
Auto-provision n8n user roles on login |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_LOGIN_ENABLED |
true |
Show OIDC login button on n8n sign-in |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_CLIENT_ID |
n8n-sso |
OIDC client ID |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_CLIENT_SECRET |
generated | OIDC client secret (regenerated on install) |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_DISCOVERY_ENDPOINT |
app.url /.well-known/… | OIDC discovery URL |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_PROMPT |
empty | OIDC prompt parameter (login, consent, etc.) |
SAASFORGE_RUNTIME_N8N_SSO_OIDC_ACR_VALUES |
empty | OIDC acr_values parameter |
| SaaSForge — SSH Runtime | ||
SAASFORGE_SSH_RUNTIME_ENABLED |
true |
Enable the SSH runtime driver |
SAASFORGE_SSH_CONNECT_TIMEOUT |
10 |
SSH connection timeout in seconds |
SAASFORGE_SSH_WAIT_TIMEOUT |
180 |
Wait timeout for remote operations |
SAASFORGE_SSH_RUNTIME_ROOT_PATH |
/var/lib/saasforge/runtime |
Remote path for runtime files |
SAASFORGE_SSH_DOCKER_BIN |
docker |
Remote Docker binary path |
SAASFORGE_SSH_DOCKER_COMPOSE_COMMAND |
docker compose |
Remote compose command |
SAASFORGE_SSH_RUNTIME_IMAGE |
docker.n8n.io/n8nio/n8n:latest |
Remote n8n image tag |
SAASFORGE_SSH_RUNTIME_BASE_PORT |
23000 |
Remote base port |
SAASFORGE_SSH_RUNTIME_CPUS |
0.5 |
Remote CPU limit |
SAASFORGE_SSH_RUNTIME_MEMORY |
600m |
Remote memory limit |
SAASFORGE_SSH_RUNTIME_MEMORY_SWAP |
700m |
Remote swap limit |
SAASFORGE_SSH_RUNTIME_RUNNERS_ENABLED |
true |
Remote task runners |
SAASFORGE_SSH_CADDY_RELOAD |
true |
Remote Caddy reload after site changes |
SAASFORGE_SSH_CADDY_BIN |
caddy |
Remote Caddy binary path |
SAASFORGE_SSH_CADDY_MAIN_CONFIG |
/etc/caddy/Caddyfile |
Remote main Caddyfile |
SAASFORGE_SSH_CADDY_SITE_DIR |
/etc/caddy/sites-enabled/saasforge |
Remote Caddy site files directory |
| SaaSForge — Backups & Health | ||
SAASFORGE_BACKUPS_ENABLED |
true |
Enable scheduled runtime backups |
SAASFORGE_BACKUPS_MAX_PER_TENANT |
7 |
Max backups retained per tenant |
SAASFORGE_HEALTH_CHECKS_ENABLED |
true |
Enable scheduled health checks |
SAASFORGE_HEALTH_CURL_TIMEOUT |
5 |
Health check HTTP timeout in seconds |
SAASFORGE_HEALTH_FAILURE_THRESHOLD |
3 |
Consecutive failures before instance marked unhealthy |
| SaaSForge — Domains & TLS | ||
SAASFORGE_CADDY_ON_DEMAND_TLS_ENABLED |
true |
Enable Caddy on-demand TLS for custom domains |
| N8NForge — Instances | ||
N8NFORGE_PRODUCT_TYPE |
n8nforge |
Product type for N8NForge instances |
N8NFORGE_DEFAULT_N8N_VERSION |
latest |
Default n8n version tag for new instances |
N8NFORGE_INSTANCE_SUBFOLDER |
/ |
Subfolder path for instance URL |
N8NFORGE_INSTANCE_RANDOM_SLUG_PREFIX |
inst |
Prefix for random slug when instance slug would repeat the tenant slug |
| N8NForge — Templates | ||
N8NFORGE_AUTO_INSTALL_FEATURED |
true |
Auto-queue featured templates on new workspaces |
N8NFORGE_AUTO_INSTALL_STATUS |
queued |
Status for auto-installed templates |
N8NFORGE_MANUAL_INSTALL_STATUS |
queued |
Status for manually installed templates |
| N8NForge — Metrics | ||
N8NFORGE_METRICS_POLLING |
false |
Enable background metrics polling via scheduler |
N8NFORGE_METRICS_POLLING_INTERVAL |
15 |
Minutes between metric collection cycles |
N8NFORGE_METRICS_RETENTION_DAYS |
60 |
Days to retain metric snapshots before cleanup |
| N8NForge — Updates | ||
N8NFORGE_UPDATE_SERVER_URL |
empty | Update server URL for platform releases |
N8NFORGE_UPDATE_REQUEST_TIMEOUT |
120 |
Timeout for update server requests |
Branding, billing keys, landing copy, and docs visibility are meant to be changed from admin. Runtime topology, Docker and proxy paths, domain defaults, and SSO settings remain infrastructure configuration.
Instance resource limits
Every n8n container gets default CPU, memory, and swap limits. These can be overridden per instance from the admin panel.
- Open Automation → Managed Instances → Edit on the instance you want to adjust.
- The Resource Overrides section lets you set CPU, memory, memory swap, and toggle task runners.
- When saved, the instance restarts automatically to apply the new limits.
Metrics collection
Each n8n instance exposes a metrics endpoint that feeds the customer dashboard stat cards, history bars, and the admin Top Resource Consumers widget (shows the 10 instances with highest memory usage).
Metrics are captured automatically when customers view their dashboard. An optional
background poller can be enabled via N8NFORGE_METRICS_POLLING=true to collect
data across all instances regardless of dashboard activity. Snapshots are stored in the
n8nforge_instance_metrics table.
Retention & cleanup
Old snapshots can be pruned automatically to keep the metrics table lean:
- Retention window: controlled by
N8NFORGE_METRICS_RETENTION_DAYS(default 60). Rows older than this threshold are candidates for deletion. - Manual cleanup:
php artisan n8nforge:clean-metrics— add--dry-runto preview without deleting. - Scheduled cleanup: the framework console kernel registers
n8nforge:clean-metricson thedailyschedule. Make sure the Laravel scheduler runs every minute via cron:* * * * * cd /path/to/project && php artisan schedule:run.
Plans and billing
Commercial delivery- Provider choose Stripe or Paddle in Setup.
- Credentials save publishable, secret, and webhook values in encrypted storage.
- Plan pricing enter monthly, yearly, or one-time provider price IDs on the plan resource.
- Role assignment map each plan to the correct post-subscription role.
- Free tier handling mark the fallback plan if unsubscribed users should keep some access.
- Delivery policy use the plan resource to map provisioning servers, external databases, and external storage pools to each offer.
SaaSForge stores the active Wave plan_id on the tenant as
product_reference. That value is what server and runtime dependency
selectors
use when applying plan-level placement rules.
Provisioning model
Runtime orchestrationProvisioning is driver-based and job-driven. Initial deploy, suspend, resume, restart, redeploy, and domain sync are all tracked lifecycle jobs.
- Driver selection persists as
provisioning_driveron the tenant. - Server placement persists as
provisioning_server_idso later health, backup, and lifecycle actions use the same host. - Dependency placement persists selected external database and storage backends so restarts and redeploys keep using the same infrastructure choices.
- Provisioning jobs are the audit trail for all tracked runtime actions.
Uses the application host as the runtime host.
Uses reusable remote provisioning servers selected from plan mappings.
The same placement model can support later API-based runtime providers.
Provisioning servers define SSH connection settings, runtime defaults, reverse proxy behavior, and optional plan-level tenant capacity. Treat them as the actual hosts that carry tenant traffic.
Domain sync does not register domains with a DNS provider and does not use a wildcard
Caddy listener with an ask endpoint back to the main app. Instead,
SaaSForge
writes a concrete Caddy site file for each tenant using that tenant's exact
domain_hosts list, then reloads Caddy and updates the n8n container
environment so N8N_HOST, N8N_EDITOR_BASE_URL, and
WEBHOOK_URL point at the tenant's primary domain.
- Docker and Compose must already be installed on the provisioning host and available to the configured deploy user.
- Caddy must already be installed if you enable reverse-proxy management for that server. SaaSForge writes per-tenant site files and reloads Caddy, but it does not install or bootstrap Caddy for you.
- A writable runtime root must exist for compose files, env files, logs, backups, and tenant data.
- A shared Docker network must be available or creatable by the deploy user for tenant containers.
- DNS must point tenant domains at the provisioning host or fronting load balancer before HTTPS traffic can succeed.
- Firewall and ports must allow public HTTP/HTTPS traffic to the reverse proxy and local container traffic on the allocated host ports.
- Prepare the host with Docker, Docker Compose, Caddy, and a deploy user that can write the runtime directories and reload Caddy.
- Point DNS for any tenant or base-domain records at that host or its fronting proxy/load balancer.
- Create a provisioning server in admin with the SSH connection, root path, Docker command names, network, and optional Caddy paths.
- Assign the server to plans so new tenants can be placed onto that host automatically.
- Run a real provisioning test and confirm the tenant compose files, Caddy site file, Caddy reload, HTTPS response, and n8n launch URL all succeed.
- Creates tenant runtime files such as the compose file, env file, log path, backup path, and tenant data path under the configured runtime root.
- Creates or updates a tenant-specific Caddy site file that contains the tenant's exact domain list and reverse proxies traffic to that tenant's allocated host port.
- Starts, stops, restarts, or redeploys the tenant container with the configured Docker and Compose commands.
- Reloads Caddy after writing or removing a tenant site file when reverse proxy management is enabled.
- Updates n8n runtime URLs so the primary tenant domain becomes the editor base URL and webhook base URL.
- It does not install Docker or Docker Compose on the provisioning host.
- It does not install or bootstrap Caddy on the provisioning host.
- It does not create DNS records with Cloudflare, Route53, or any other DNS provider.
- It does not create a wildcard or catch-all Caddy listener that asks the central app whether a domain is allowed.
- It does not manage operating-system firewall rules or cloud load balancer listeners for you.
Treat provisioning hosts as pre-built runtime targets. SaaSForge expects the server to be ready for deployments before you save it in admin.
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_USER="${1:-deploy}"
HOSTNAME="$(hostname -f)"
_has_cmd() { command -v "$1" || return 1; }
_deb_installed() { dpkg -s "$1" || return 1; }
_install_if_missing() {
local missing=()
for pkg in "$@"; do
_deb_installed "$pkg" || missing+=("$pkg")
done
[[ ${#missing[@]} -eq 0 ]] || sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}"
}
echo "=== 1. System updates ==="
sudo apt-get update -qq
sudo apt-get upgrade -y -qq
_install_if_missing curl ufw unattended-upgrades
if ! swapon --show | grep -q .; then
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
fi
echo "=== 2. Docker ==="
if ! _has_cmd docker; then
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do
sudo apt-get remove -y "$pkg" || true
done
curl -fsSL https://get.docker.com | sudo bash
fi
_install_if_missing docker-compose-plugin
echo "=== 3. Docker daemon config ==="
sudo mkdir -p /etc/docker
printf '%s\n' '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"},"live-restore":true,"storage-driver":"overlay2"}' \
| sudo tee /etc/docker/daemon.json
if _has_cmd dockerd; then
sudo systemctl restart docker
fi
echo "=== 4. Caddy ==="
if ! _has_cmd caddy; then
_install_if_missing debian-keyring debian-archive-keyring apt-transport-https
sudo mkdir -p /usr/share/keyrings
if [[ ! -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg ]]; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
fi
if [[ ! -f /etc/apt/sources.list.d/caddy-stable.list ]]; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
fi
sudo apt-get update -qq
_install_if_missing caddy
fi
echo "=== 5. Deploy user & runtime dirs ==="
sudo id -u "$DEPLOY_USER" || sudo useradd -m -s /bin/bash "$DEPLOY_USER"
sudo usermod -aG docker "$DEPLOY_USER"
sudo mkdir -p /var/lib/saasforge/runtime
sudo chown -R "$DEPLOY_USER":"$DEPLOY_USER" /var/lib/saasforge/runtime
sudo mkdir -p /etc/caddy/sites-enabled/saasforge
echo "=== 6. Kernel tuning ==="
printf 'fs.file-max = 100000\nvm.max_map_count = 262144\nnet.core.somaxconn = 1024\n' \
| sudo tee /etc/sysctl.d/99-saasforge.conf
sudo sysctl --system
echo "=== 7. Firewall ==="
if sudo ufw status | grep -q 'Status: active'; then
for port in ssh 80/tcp 443/tcp; do
sudo ufw status | grep -q "$port" || sudo ufw allow "$port"
done
else
_install_if_missing ufw
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
fi
echo "=== 8. Enable services ==="
sudo systemctl enable --now docker
# Write a minimal Caddyfile so caddy can start
if ! caddy validate --config /etc/caddy/Caddyfile; then
printf ':80 {\n respond "SaaSForge provisioning host"\n}\n\nimport /etc/caddy/sites-enabled/saasforge/*\n' \
| sudo tee /etc/caddy/Caddyfile
fi
cd /etc/caddy && caddy start
echo "Caddy started in /etc/caddy/"
# unattended-upgrades is a timer, not a service
sudo systemctl enable unattended-upgrades || true
sudo systemctl start unattended-upgrades || true
echo "=== 9. Verification ==="
echo -n "Docker: "; sudo -u "$DEPLOY_USER" docker version --format '{{.Server.Version}}'
echo -n "Compose: "; sudo -u "$DEPLOY_USER" docker compose version --short
echo -n "Caddy: "; caddy version
echo -n "UFW: "; sudo ufw status | head -1
echo ""
echo "Host $HOSTNAME ready. Add it as a Provisioning Server in admin.
Host : $HOSTNAME
Port : 22
Username : $DEPLOY_USER"
The main Caddy config should import the SaaSForge-managed site directory. SaaSForge
writes
individual tenant site files into that directory and then reloads Caddy. If you want
on-demand TLS protection for tenant and custom domains, add the global
on_demand_tls ask endpoint that points at the central app.
# Example /etc/caddy/Caddyfile excerpt
{
email admin@example.com
on_demand_tls {
ask https://app.example.com/saasforge/caddy/on-demand-tls
}
}
import /etc/caddy/sites-enabled/saasforge/*
When SAASFORGE_CADDY_ON_DEMAND_TLS_ASK_URL is configured, SaaSForge-generated
site
files switch to tls { on_demand } and expect the main Caddy config to expose
the
matching ask URL above.
Point tenant domains or delegated subdomains at the provisioning host or its fronting load balancer before expecting HTTPS to succeed. Domain sync only updates the runtime and Caddy side after the hostname is already routed to the host.
A brand-new SSH target can fail on its first deployment if the app host has never trusted
that
server before. When strict host key checking is enabled, trust the host in
known_hosts first or disable strict checking temporarily for the first
test.
External runtime dependencies
Database and storage poolsManaged PostgreSQL pools with host, port, database, credentials, schema, pool sizing, optional SSL material, and plan-level capacity mapping.
Managed S3-compatible binary-data storage backends with endpoint, protocol, bucket, region, credentials, and plan-level capacity mapping.
- Fallback behavior remains local SQLite plus local filesystem storage when no external resource is assigned.
- Tenant overrides can explicitly pin a tenant to a specific database or storage backend.
- Plan mappings let you keep infrastructure placement policy on the product offer instead of per-tenant manual edits.
External binary-data storage depends on n8n enterprise licensing. If you are not intentionally using that feature, leave storage unmapped and back up the tenant data directory on the runtime host.
Tenant operations
Day-two admin work- Tenant edit page is for ownership, plan changes, infrastructure overrides, and primary record maintenance.
- Tenant instance page is for day-two operations such as health review, uptime visibility, backup creation, restart, and redeploy.
- Provisioning feedback allows readiness callbacks to close a running lifecycle job after the runtime reports back.
- Runtime metadata stores base URL, launch URL, host port, latest failure details, and dependency selections.
When a tenant looks unhealthy, open the instance page before taking action. Review the latest health snapshot, uptime, active host, latest backup, and last provisioning job so you can choose the right response.
Scheduler and commands
Required automationThis is one of the most important parts of setup. If the scheduler is not running, new tenants stay queued, template installs wait, health checks stop, backups stop, and scheduled account actions do not run.
You do not create one cron job for each command below.
You create one server cron job that runs
php artisan schedule:run
every minute. Laravel then runs the correct commands automatically.
Find the app folder
Open the folder where N8Forge is installed. This is the folder that contains the
artisan file.
Test the scheduler once
Run php artisan schedule:run from that folder. If it works, you can
set
the server to run the same command automatically every minute.
Add one cron job on the server
In your server panel or cron manager, create a job that runs every minute. Replace the sample path below with your real N8Forge install path and PHP path.
* * * * * cd /path/to/n8forge && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
If your host asks for separate fields, use:
- Frequency: every minute
- Working folder: the N8Forge folder that contains
artisan - Command:
/usr/bin/php artisan schedule:run
Confirm it is working
After a few minutes, create or update something that uses automation and confirm the related action moves forward. A newly created tenant should not remain stuck in queued for long if the scheduler is active.
Run the cron job on only one main app server unless you have intentionally designed a clustered scheduler setup.
| Command | Schedule | Purpose |
|---|---|---|
subscriptions:cancel-expired |
Every hour | Cancels expired subscriptions automatically |
saasforge:send-suspension-reminders |
Every hour | Sends scheduled suspension reminders |
saasforge:process-provisioning |
Every minute | Processes tenant create, restart, redeploy, suspend, and destroy jobs |
saasforge:check-runtime-health |
Every five minutes | Updates runtime health snapshots and instance status data |
saasforge:run-backups |
Daily | Creates tenant backups |
n8nforge:process-template-installs |
Every minute | Processes queued template installs for managed workspaces |
accounts:process-deletions |
Daily | Deletes accounts that reached the end of their deletion grace period |
activity:clean |
Daily | Removes old activity logs based on retention settings |
Create a test tenant. If the scheduler is working, provisioning should begin shortly
instead of sitting in queued.
The cron job is missing, using the wrong path, using the wrong PHP binary, or running in the wrong folder.
Send your hosting provider or server admin the cron command above and ask them to run it every minute on the N8Forge application server.
Backups and restore
Automated backups capture the full n8n data directory, including the SQLite database, credentials, and workflow files.
- Scope: The entire
/home/node/.n8ndirectory is tar'd, so the SQLite database is always included. - Schedule:
saasforge:run-backupsruns daily. Retention is configurable viaSAASFORGE_BACKUPS_MAX_PER_TENANT(default 7). - Manual backup: From the instance edit page at Automation → Managed Instances → Edit → Backup.
- Restore: From the instance view page (Automation → Managed Instances → {instance} → View), scroll to the Backups section and click Restore on a succeeded backup. The container stops, the data directory is replaced, and the container restarts.
- Restore status is shown as a badge on the backup card. All restore operations are logged to the activity log.
- SSH driver: Backup files are downloaded locally, then uploaded back to the remote server during restore. Both Docker and SSH drivers are supported.
Activity log
A unified activity log at SaaS → Activity Log shows all platform operations in reverse chronological order.
| Action type | Source | What it tracks |
|---|---|---|
saasforge_job_* |
Provisioning | Every job start, success, and failure |
n8nforge_template_* |
Templates | Template installs and failures |
saasforge_runtime_backup_* |
Backups | Backup queued, completed, or failed |
saasforge_runtime_restore_* |
Restore | Restore started, completed, or failed |
saasforge_runtime_health_* |
Health | Health check results |
saasforge_runtime_*_queued |
Actions | Restart, redeploy, or destroy queued |
Filter by action type or time period (today, yesterday, last 7/30 days). Error details are shown inline. Logs older than the configured retention period are cleaned automatically.
Without the every-minute scheduler cron job, provisioning, template delivery, reminders, cleanup, backups, health monitoring, and scheduled account actions will drift or stop.
Updates and deployment tooling
Release operations- Admin → Updates verifies the purchase code, checks for the latest release, and applies downloads when configured.
php artisan n8forge:updateruns pending update tasks for the current install.php artisan n8forge:update --downloaddownloads and applies the latest release before migrations and cache refresh.ops/scripts/build-mainfiles.shbuilds install and update packages.ops/scripts/push-demo.shhelps deploy the N8Forge app itself, not tenant runtimes.
The ops/ scripts are for deploying the N8Forge application. They are not a
replacement for SaaSForge provisioning servers or tenant runtime placement.
Maintenance workflow
Recommended routine- Daily review provisioning jobs, health checks, failed backups, and account notifications. Open the instance page for any degraded workspace.
- After plan changes verify billing IDs, role mapping, and provisioning, database, and storage assignments.
- After server changes confirm SSH credentials, Docker access, proxy paths, and capacity limits.
- After dependency changes validate one real tenant deployment before treating the change as complete.
- Before updates take backups and verify restore confidence for both the app and tenant runtime data.
Keep ACTIVITY_LOG_ENABLED active if you need an audit trail for runtime actions
such as health checks, backups, restart, or redeploy operations.
Troubleshooting
Common admin issuesCheck the active plan, subscription status, role mapping, and whether the observer, queue, and scheduler path is functioning.
Confirm saasforge:process-provisioning is scheduled and that the selected
driver, server, and optional runtime dependencies are valid.
Verify host, port, username, key or password, host-key policy, Docker access, remote paths, and whether the deploy user can write files and reload the reverse proxy.
Check that DNS points at the correct host, the tenant Caddy site file exists on that
provisioning server, Caddy reloaded successfully, and the primary domain matches the
n8n runtime base URL. There is no central wildcard ask-endpoint flow in
the current implementation.
Check credentials, SSL settings, S3 region formatting, enterprise licensing, and whether the plan or tenant still points at the intended dependency pool.
Open the tenant instance page first, review the last health result and active host, then decide between health check, backup, restart, or redeploy.