n8n is a workflow automation platform that connects APIs, schedules jobs, runs webhooks, and stores third-party credentials. Self-hosting n8n gives you control of its runtime and data, but it also makes the VPS a privileged automation system. A compromised owner account or workflow can reach every credential and network destination that n8n can reach.
This guide deploys n8n's stable container with PostgreSQL and Caddy on Ubuntu 24.04 LTS. PostgreSQL stays on the private Compose network, n8n has no public host port, and Caddy is the only public web entry point.
n8n VPS requirements and LayerOne cost
| Item | Practical starting point |
|---|---|
| Minimum RAM | 2 GB for a small n8n, PostgreSQL, and proxy stack without AI sandbox services |
| Recommended RAM | 4 GB for concurrent executions, upgrades, and moderate binary data |
| Expected CPU usage | Roughly 2–10% of one vCPU while the stack is idle; workflow or Code-node executions can briefly or continuously use every assigned core |
| Storage requirement | 20 GB minimum; 40 GB or more when retaining executions or processing files |
| Exact LayerOne SKU | gc.small — catalog slug layerone-4g, 2 vCPU, 4 GB RAM, 60 GB disk |
| Expected monthly cost | $8.00/month equivalent, metered at $0.0109/hour while the VPS exists |
n8n resource use follows workflow design, not just user count. A single large
JSON payload, file transformation, unbounded loop, browser task, or parallel AI
workflow can exceed this baseline. These CPU figures are operating estimates,
not guarantees. Watch docker stats, queue time, execution duration, database
growth, and the VPS monitoring guide. Use queue mode,
external task runners, and separate workers only after measuring a need and
following n8n's scaling documentation.
LayerOne bills hourly and usage can vary. The monthly equivalent is the catalog figure for a continuously existing server, not a fixed invoice guarantee. Check Pricing before ordering.
Before you install
Complete Your first hour on a new server on a fresh
Ubuntu 24.04 LTS server. Install Docker Engine and Compose with
Running Docker. Point a DNS A record such as
automate.example.com to the VPS.
This stack does not include n8n's optional AI Assistant sandbox. Current n8n documentation specifies at least 4 GB and 2 vCPUs for that fuller sandbox stack; size up after measuring if you add it.
1. Set the LayerOne and host firewalls
On the server's LayerOne Networking tab, select inbound DROP and outbound
ACCEPT. Allow TCP 22 only from your administration address, plus public TCP 80
and 443. Do not open n8n port 5678 or PostgreSQL port 5432. See
The cloud firewall.
Mirror that policy with UFW:
sudo apt update
sudo apt install -y ufw openssl
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from YOUR.ADMIN.IP.ADDRESS to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Docker-published ports can bypass UFW. The upstream LayerOne firewall is a required second boundary. The Compose file below publishes only Caddy's ports.
2. Create secrets and persistent storage
sudo mkdir -p /opt/n8n
sudo chown -R "$USER":"$USER" /opt/n8n
cd /opt/n8n
install -m 600 /dev/null .env
N8N_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
{
echo 'N8N_HOST=automate.example.com'
echo "N8N_ENCRYPTION_KEY=$N8N_KEY"
echo "POSTGRES_PASSWORD=$DB_PASSWORD"
echo 'GENERIC_TIMEZONE=America/New_York'
} > .env
unset N8N_KEY DB_PASSWORD
Replace the hostname and timezone in .env. The n8n encryption key protects
stored credentials; losing it can make a database backup useless. Keep .env
mode 600 and save the key in an off-server password manager.
Create /opt/n8n/compose.yaml:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: n8n
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 10
n8n:
image: docker.n8n.io/n8nio/n8n:stable
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: "5432"
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: "${POSTGRES_PASSWORD}"
N8N_HOST: "${N8N_HOST}"
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: "https://${N8N_HOST}/"
N8N_PROXY_HOPS: "1"
N8N_ENCRYPTION_KEY: "${N8N_ENCRYPTION_KEY}"
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_RUNNERS_ENABLED: "true"
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
GENERIC_TIMEZONE: "${GENERIC_TIMEZONE}"
TZ: "${GENERIC_TIMEZONE}"
volumes:
- n8n_data:/home/node/.n8n
expose:
- "5678"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- n8n
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
postgres_data:
n8n_data:
caddy_data:
caddy_config:
Execution pruning is set to 336 hours, or 14 days. Change it to match your audit and troubleshooting needs, but do not retain every successful execution by accident. Binary payloads and database rows can fill a disk quickly.
Create /opt/n8n/Caddyfile:
automate.example.com {
encode zstd gzip
reverse_proxy n8n:5678
}
Make the hostname match .env exactly.
3. Start n8n and create the owner
cd /opt/n8n
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=100 postgres n8n caddy
Open https://automate.example.com immediately and create the instance owner.
Use a unique password and enable MFA. Do not leave an unclaimed first-run
instance exposed.
Verify HTTPS, health, and sockets:
curl -fsS https://automate.example.com/healthz
docker compose ps
sudo ss -lntp | grep -E ':(80|443|5678|5432)[[:space:]]'
Only 80 and 443 should be public listeners. Ports 5678 and 5432 should not
appear on 0.0.0.0. Create a manual workflow with a Schedule Trigger and a Set
node, run it, activate it, and confirm a scheduled execution. If the service
receives external webhooks, use a disposable webhook workflow and verify its
production URL uses the HTTPS hostname rather than an internal container name.
4. Control execution and file growth
Review execution settings in the n8n UI. Save failed executions when they help diagnosis, limit successful-execution retention, and avoid storing large binary payloads longer than necessary. Check growth regularly:
docker system df
docker compose exec postgres psql -U n8n -d n8n -c 'SELECT pg_size_pretty(pg_database_size(current_database()));'
df -h /var/lib/docker
Do not use docker system prune --volumes; the named volumes are the database
and application state. Apply memory and concurrency limits based on measured
workflows, not a guessed global value that kills legitimate jobs.
5. Back up n8n correctly
An n8n recovery needs both its PostgreSQL data and the same
N8N_ENCRYPTION_KEY. Workflow JSON exports alone do not preserve usable stored
credentials.
Pause n8n so its application volume cannot change while PostgreSQL is dumped,
then create private, timestamped archives. The subshell restarts n8n even if a
backup command fails, and pg_restore --list rejects a malformed custom dump:
cd /opt/n8n
(
set -eu
BACKUP_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
install -d -m 700 backups
umask 077
docker compose stop n8n
trap 'docker compose start n8n' EXIT
docker compose exec -T postgres pg_dump -U n8n -d n8n -Fc > "backups/n8n-${BACKUP_STAMP}.dump"
docker compose exec -T postgres pg_restore --list < "backups/n8n-${BACKUP_STAMP}.dump" >/dev/null
docker run --rm -v n8n_n8n_data:/data:ro -v "$PWD/backups":/backup alpine sh -c 'umask 077; tar -czf "/backup/n8n-data-${1}.tar.gz" -C /data .' _ "$BACKUP_STAMP"
sudo chown "$(id -u):$(id -g)" "backups/n8n-data-${BACKUP_STAMP}.tar.gz"
tar -czf "backups/n8n-config-${BACKUP_STAMP}.tar.gz" .env compose.yaml Caddyfile
)
Compose normally names the volume n8n_n8n_data from the project directory.
Confirm with docker volume ls; substitute the actual name if it differs.
Encrypt and transfer all three artifacts off the VPS. Restrict the config
archive because it contains the database password and encryption key. LayerOne
does not create backups; see Backups and snapshots.
To test restoration, create an isolated fresh stack using the saved .env,
start PostgreSQL, stop n8n, load the dump with pg_restore, restore the n8n data
volume, and start n8n. Confirm workflow definitions, credential decryption,
webhook URLs, and a real test execution. Never point two restored copies at the
same live webhook or schedule set.
6. Upgrade safely
n8n releases frequently. Read the release and breaking-change notes, take an off-server backup, then update the stable channel:
cd /opt/n8n
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 n8n postgres
curl -fsS https://automate.example.com/healthz
Keep n8n main, workers, and external runners on the same version if you later add those components. Test login, credential access, one manual workflow, one scheduled workflow, and one webhook before pruning old images.
Security pitfalls
- Treat workflow edit access as code execution and credential access. Grant it only to trusted operators.
- Review community nodes before installation; they execute inside the n8n environment. Remove unused nodes and credentials.
- Run n8n's documented security audit periodically:
docker compose exec n8n n8n audit. - Restrict or disable risky nodes such as Execute Command when the instance does not need them, and follow n8n's runner-hardening guidance for untrusted code.
- Do not expose PostgreSQL, n8n port 5678, Docker, or a Traefik/Caddy admin API.
- Rotate any secret that appears in execution data, logs, screenshots, exports, or source control.