Education· 8 min read

How to Self-Host n8n for Free on Oracle Cloud (2026 Guide)

A step-by-step walkthrough for running your own n8n instance on Oracle's Always Free tier - ARM VM, Docker, Postgres, automatic SSL, and backups, for free.

N8N Chat UI Team

N8N Chat UI Team

How to Self-Host n8n for Free on Oracle Cloud (2026 Guide)

You've built a workflow in n8n and you're tired of the cloud plan's execution limits, or you just don't want to pay a monthly fee for something that can run comfortably on a free server. Good news: Oracle Cloud's Always Free tier gives you a real ARM VM — not a toy — that can run n8n, Postgres, and a reverse proxy with automatic SSL, for £0/month. You only pay for the domain.

This guide walks through the whole setup: account, VM, firewall (the part everyone gets stuck on), Docker, DNS, deployment, backups, and how to stop Oracle reclaiming your instance for looking idle.

What you'll have by the end
  • A free ARM VM running n8n behind Caddy, with automatic HTTPS on your own domain
  • Postgres as the backing database instead of SQLite
  • Nightly backups of your workflows and credentials
  • A setup that survives Oracle's idle-instance reclamation policy


Time: 60–90 minutes, assuming you get server capacity on the first try (more on that below).
Cost: £0/month for the server. You only pay for the domain.

Before you start: the 2026 free tier change

Oracle halved the Always Free Ampere allocation in June 2026. If you've read an older guide that says "4 OCPU / 24 GB," that figure is out of date.

  • Ampere A1 (ARM) compute: 2 OCPU / 12 GB RAM total, across all A1 instances
  • AMD micro instances: 2 × VM.Standard.E2.1.Micro (1/8 OCPU, 1 GB each)
  • Block storage: 200 GB total, minimum 50 GB per boot volume
  • Outbound data transfer: 10 TB/month
  • Load balancer: 1 flexible LB at 10 Mbps

2 OCPU / 12 GB is still comfortably more than enough for n8n — it'll handle hundreds of executions a day without breaking a sweat. The AMD micro instances are not enough to run this stack; you want the ARM shape.

1. Always Free resources only exist in your tenancy's home region. You pick the home region at signup and you cannot change it later. Choose the one closest to you.

2. Idle instances get reclaimed. Oracle may delete your VM if, over a 7-day window, CPU, network, and memory all sit under 20% at the 95th percentile. The last section of this guide covers how to avoid that.

Step 1: Create the Oracle Cloud account

  1. Go to Oracle's cloud free tier signup page and register.
  2. You'll need a card for identity verification. Oracle takes a small pre-auth (roughly £1) and reverses it — Always Free resources never actually charge the card.
  3. Set your home region carefully. This choice is permanent.
  4. Verification can take anywhere from minutes to a couple of days. If a card gets rejected for no clear reason, trying a different card usually fixes it.

You start on a 30-day trial with credit. When that expires, the account drops to Always Free only, and your instance keeps running as long as it's within the free limits.

Step 2: Generate an SSH key

On your machine:

ssh-keygen -t ed25519 -C "n8n-oracle" -f ~/.ssh/n8n_oracle
cat ~/.ssh/n8n_oracle.pub

Keep that public key handy — you'll paste it into the console in the next step.

Step 3: Create the VM

In the OCI console: Menu → Compute → Instances → Create instance.

  • Name: n8n-prod
  • Placement: any availability domain (try each if one is out of capacity)
  • Image: Ubuntu 24.04 (or Oracle Linux 9, if you prefer)
  • Shape: VM.Standard.A1.Flex — 2 OCPU, 12 GB RAM
  • Boot volume: 50 GB (default is fine)
  • Networking: create a new VCN, assign a public IPv4 address
  • SSH keys: paste the public key from Step 2

Click Create.

If the only free shape you can see is VM.Standard.E2.1.Micro

The Ampere shapes are on a separate tab. Click Change shape → Ampere → VM.Standard.A1.Flex, then set OCPUs to 2 and memory to 12 GB. The "Always Free-eligible" label only appears once you've dialled the allocation down inside the free limits — at the default setting it looks like a paid shape, which is why a lot of people assume ARM isn't available to them.

Also double check the region selector is on your home region — Always Free resources exist nowhere else. And don't settle for the E2.1.Micro: at 1/8 OCPU and 1 GB RAM, it can't run n8n, Postgres, Caddy, and a task runner together.

If you get "Out of host capacity"

This is the single most common blocker. ARM capacity in popular regions is frequently exhausted. In order of effort:

  1. Try a different availability domain. Regions with AD-1/2/3 often have capacity in only one of them.
  2. Retry on a loop. Capacity frees up constantly — most people succeed within a few hours to a few days of retrying.
  3. Switch to Pay As You Go. Counter-intuitively, PAYG accounts get priority for capacity, and Oracle still doesn't charge for anything inside the Always Free limits. Set a $0 budget alert so you'd know immediately if you ever crossed a line.

Step 4: Open the firewall — both layers

This is the step almost everyone gets wrong first time. Oracle blocks traffic in two separate places, and you have to open both.

Layer 1: the VCN security list

Menu → Networking → Virtual Cloud Networks → your VCN → Subnets → your subnet → Security Lists → default list → Add Ingress Rules. Add two rules:

  • 0.0.0.0/0, TCP, port 80
  • 0.0.0.0/0, TCP, port 443

Layer 2: iptables on the instance itself

Oracle's Ubuntu images ship with restrictive iptables rules that ignore whatever the security list says. SSH in first:

ssh -i ~/.ssh/n8n_oracle ubuntu@YOUR_SERVER_IP

Then add the rules:

sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT
sudo netfilter-persistent save

Verify both ports are accepted:

sudo iptables -L INPUT -n --line-numbers | grep -E "dpt:(80|443)"

If you skip layer 2, your site will time out and everything else will look correctly configured — the security list is open, DNS resolves, Caddy is running. Don't skip it.

Step 5: Basic hardening

sudo apt update && sudo apt upgrade -y

# Automatic security patches
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Disable password logins so only your key works:

sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh

Optional but worth doing — fail2ban for SSH:

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

Step 6: Install Docker

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
docker --version && docker compose version

The official ARM64 images work fine here — no special handling needed for the Ampere architecture.

Step 7: Point your domain at the server

If you're using Cloudflare for DNS, add an A record:

  • Type: A
  • Name: n8n
  • IPv4 address: your server's IP
  • Proxy status: DNS only (grey cloud)

This is the Cloudflare equivalent of the iptables trap in Step 4. With the orange cloud on, DNS resolves to a Cloudflare edge IP, Let's Encrypt's HTTP-01 challenge reaches Cloudflare instead of your Caddy container, and certificate issuance fails with an error that looks nothing like a DNS problem.

You can put Cloudflare in front later for its WAF or caching — just do it after the stack is working, and set SSL/TLS mode to Full (strict) when you do. Anything less and Cloudflare will either refuse the origin cert or serve your site over an unencrypted hop to it.

Wait for the record to resolve before continuing:

dig +short n8n.your-domain.com

Don't move on until that returns your server's actual IP. If you get something in the 104.x.x.x or 172.67.x.x range, the proxy is still on.

Step 8: Deploy n8n

mkdir -p ~/n8n && cd ~/n8n

.env

cat > .env <<'EOF'
# --- Domain ---
DOMAIN_NAME=your-domain.com
SUBDOMAIN=n8n
GENERIC_TIMEZONE=Europe/London

# --- n8n version: pin it, don't use :latest ---
N8N_VERSION=2.32.7

# --- Postgres ---
POSTGRES_USER=postgres
POSTGRES_PASSWORD=CHANGE_ME_ROOT
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=n8n
POSTGRES_NON_ROOT_PASSWORD=CHANGE_ME_APP

# --- Secrets ---
N8N_ENCRYPTION_KEY=CHANGE_ME_ENCRYPTION
RUNNERS_AUTH_TOKEN=CHANGE_ME_RUNNER
EOF

Generate real secrets and substitute them in:

for k in ROOT APP ENCRYPTION RUNNER; do
  echo "$k: $(openssl rand -hex 32)"
done

Edit .env with nano .env, replacing each CHANGE_ME_* value, then lock the file down:

chmod 600 .env

Save N8N_ENCRYPTION_KEY somewhere safe — a password manager, not a note file. It encrypts every credential you store in n8n. Lose it and every credential becomes unreadable, even with a full database backup.

init-data.sh

Creates the non-root Postgres user n8n connects as:

cat > init-data.sh <<'EOF'
#!/bin/bash
set -e;

if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
	psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
		CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
		GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
		GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
	EOSQL
else
	echo "SETUP INFO: No Environment variables given!"
fi
EOF

chmod +x init-data.sh

Caddyfile

mkdir -p caddy_config
cat > caddy_config/Caddyfile <<'EOF'
n8n.your-domain.com {
    reverse_proxy n8n:5678 {
      flush_interval -1
    }
}
EOF

flush_interval -1 turns off response buffering, which n8n needs for live execution streaming to work in the editor.

docker-compose.yml

volumes:
  db_storage:
  n8n_storage:
  caddy_data:
  caddy_config:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy_config/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - n8n

  postgres:
    image: postgres:16
    restart: always
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
      - POSTGRES_NON_ROOT_USER
      - POSTGRES_NON_ROOT_PASSWORD
    volumes:
      - db_storage:/var/lib/postgresql/data
      - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh:ro
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: always
    environment:
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
      - DB_POSTGRESDB_USER=${POSTGRES_NON_ROOT_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_NON_ROOT_PASSWORD}
      - N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - NODE_ENV=production
      - WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
      - N8N_EDITOR_BASE_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_PROXY_HOPS=1
      - N8N_RUNNERS_MODE=external
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
    volumes:
      - n8n_storage:/home/node/.n8n
      - ./local_files:/files
    depends_on:
      postgres:
        condition: service_healthy

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: always
    environment:
      - N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
    depends_on:
      - n8n

Notice there's no ports: block on the n8n service. Only Caddy is exposed to the internet — n8n is only reachable through the reverse proxy, over the internal Docker network. Publishing port 5678 directly would bypass Caddy and expose n8n over plain HTTP.

N8N_PROXY_HOPS=1 tells n8n it's sitting behind one proxy, so it reads the real client IP from X-Forwarded-For — this matters for rate limiting and login security to behave correctly.

Start it

mkdir -p local_files
docker compose up -d
docker compose logs -f n8n

Watch for Editor is now accessible via: https://n8n.your-domain.com. Caddy fetches a Let's Encrypt certificate automatically on the first request — no certbot, no cron renewal to manage.

Open the URL and create the owner account immediately. Until you do, anyone who finds the URL can claim it.

Step 9: Backups

The Docker volumes hold everything that matters. This script dumps Postgres and the n8n data directory, keeping 14 days of history:

cat > ~/n8n/backup.sh <<'EOF'
#!/bin/bash
set -e
BACKUP_DIR=/home/ubuntu/n8n-backups
STAMP=$(date +%F-%H%M)
mkdir -p "$BACKUP_DIR"
cd /home/ubuntu/n8n

# Postgres logical dump
docker compose exec -T postgres pg_dump -U postgres n8n | gzip > "$BACKUP_DIR/n8n-db-$STAMP.sql.gz"

# n8n data dir (encryption key, binary data)
docker run --rm \
  -v n8n_n8n_storage:/data:ro \
  -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/n8n-files-$STAMP.tar.gz" -C /data .

# Keep 14 days
find "$BACKUP_DIR" -name "n8n-*" -mtime +14 -delete
EOF

chmod +x ~/n8n/backup.sh

Check the volume name matches your setup before relying on this:

docker volume ls | grep n8n

Schedule it nightly at 03:00:

(crontab -l 2>/dev/null; echo "0 3 * * * /home/ubuntu/n8n/backup.sh >> /home/ubuntu/n8n-backup.log 2>&1") | crontab -

Get the backups off the box. A local backup doesn't help if Oracle reclaims the instance. Either enable OCI block volume backups (five free volume backups are included) via Block Storage → Boot Volumes → Boot Volume Backups, or sync the folder to Object Storage, Backblaze, or Google Drive with rclone.

Test a restore at least once. An untested backup is a guess.

Step 10: Don't get reclaimed

Oracle reclaims Always Free instances that look idle. n8n ticking over quietly with a handful of workflows can genuinely fall below all three thresholds — CPU, network, and memory all under 20% at the 95th percentile, over 7 days.

This is a real policy, and a genuinely idle box is at real risk. A modest keep-warm job is the standard mitigation — a few minutes of load each hour is enough to stay clear of the threshold.
cat > ~/keepalive.sh <<'EOF'
#!/bin/bash
# ~3 min of load on one core, plus a little network traffic
timeout 180 dd if=/dev/urandom of=/dev/null bs=1M >/dev/null 2>&1
curl -s -o /dev/null https://www.google.com/generate_204
EOF

chmod +x ~/keepalive.sh
(crontab -l 2>/dev/null; echo "0 * * * * /home/ubuntu/keepalive.sh") | crontab -

If your n8n workflows are already doing real work on a schedule, check actual usage first in Compute → Instances → your instance → Metrics — you may already clear the bar naturally and not need this at all.

And if your automations become genuinely important to the business, it's worth weighing the time cost of a surprise outage against the £4–5/month a small Hetzner box costs — one nobody can reclaim.

Day-to-day operations

cd ~/n8n

docker compose ps                    # status
docker compose logs -f n8n           # follow logs
docker compose restart n8n           # restart just n8n
docker compose down                  # stop everything
docker compose up -d                 # start everything

Upgrading n8n

cd ~/n8n
./backup.sh                          # always back up first
nano .env                            # bump N8N_VERSION
docker compose pull
docker compose up -d
docker compose logs -f n8n

Pin the version rather than using :latest — n8n ships breaking changes across majors, and you want upgrades to happen on your schedule, not the registry's.

Housekeeping

Execution history will fill the disk over months. In n8n's settings, set an execution data pruning policy (e.g. keep 14 days). Prune Docker periodically too:

docker system prune -af --volumes=false

Troubleshooting

  • Site times out, no response: iptables rules missing on the instance (Step 4, layer 2)
  • Caddy can't get a certificate: DNS not resolving yet, or port 80 blocked — Let's Encrypt validates over HTTP
  • 502 Bad Gateway: n8n container not up — check docker compose logs n8n
  • Postgres auth failures: .env edited after first run — the DB was initialised with the old password
  • Webhooks return the wrong URL: WEBHOOK_URL mismatch, or a trailing-slash issue
  • Executions hang or fail oddly: runner container down — check docker compose logs n8n-runner
  • Instance vanished: reclaimed as idle, or the trial ended with resources over the free limits

Wrapping up

You've now got a free, self-hosted n8n instance with proper TLS, a real database, and backups — the same production setup you'd pay for on a managed plan, minus the monthly bill.

Now give your n8n workflow a proper front end.

Once your workflow's live, drop in a branded chat widget in minutes — no separate hosting, no extra infrastructure to maintain.