How to Run n8n on a VPS with Docker, HTTPS and Automatic Backups

A complete n8n setup on a VPS: Docker Compose, Caddy for automatic HTTPS, the environment variables that matter, updates, nightly backups and restores.

Published
Reading time
7 min

This is a full walkthrough: a fresh Debian or Ubuntu VPS at the start, and at the end n8n on your own subdomain over HTTPS, updated with one command and backed up every night. A server with 2 vCores and 4 GB of RAM is a comfortable starting size. If you have not decided between this and the hosted product, read self-hosted n8n vs n8n Cloud first.

Before you start

Run through the new VPS security checklist so the server has a sudo user, key-only SSH and a firewall. Then open the two web ports:

bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Port 80 must stay open. Caddy uses it to obtain certificates and to redirect visitors to HTTPS.

Point a subdomain at the server

Create the records at your DNS provider (record types explained):

n8n.example.com.   300   IN   A      203.0.113.10
n8n.example.com.   300   IN   AAAA   2001:db8::10

Check them before going further, because certificate issuance fails without them:

bash
dig +short A n8n.example.com
dig +short AAAA n8n.example.com

Tip: only publish the AAAA record if the server's IPv6 address really answers. The certificate authority prefers IPv6 when the record exists, so a wrong AAAA record produces a certificate error that looks unrelated to DNS.

Install Docker

Docker's convenience script installs Docker Engine and the Compose plugin from Docker's own repository:

bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
docker --version
docker compose version

The manual repository method is in the Docker Engine install documentation. Either way you want Compose v2, which is docker compose with a space.

The compose file

bash
sudo mkdir -p /opt/n8n/backups
sudo chown 1000:1000 /opt/n8n/backups
cd /opt/n8n
openssl rand -hex 32        # prints a key for the next file

The chown is there because n8n runs inside the container as user node, UID 1000, and has to write exports into that directory.

Create /opt/n8n/.env, then run sudo chmod 600 /opt/n8n/.env:

ini
DOMAIN=n8n.example.com
GENERIC_TIMEZONE=Europe/London
N8N_ENCRYPTION_KEY=paste-the-openssl-output-here

Create /opt/n8n/docker-compose.yml:

yaml
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    environment:
      - N8N_HOST=${DOMAIN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://${DOMAIN}/
      - N8N_SECURE_COOKIE=true
      - N8N_PROXY_HOPS=1
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
    volumes:
      - n8n_data:/home/node/.n8n
      - ./backups:/backups

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

volumes:
  n8n_data:
  caddy_data:
  caddy_config:

The n8n service has no ports: section on purpose. Caddy reaches it over the internal Compose network, and nothing on the internet can talk to port 5678 directly.

What the variables do

  • N8N_HOST and N8N_PROTOCOL tell n8n the public name and scheme it is served under.
  • WEBHOOK_URL is the address n8n shows in webhook nodes and registers with outside services. Behind a proxy, leaving it out gives you webhook URLs that point at an internal address.
  • N8N_SECURE_COOKIE keeps the login cookie HTTPS-only. If the editor complains about a secure cookie, the fix is to reach it over HTTPS, not to turn this off.
  • N8N_PROXY_HOPS=1 tells n8n there is one reverse proxy in front, so it reads the real client address from the forwarded headers.
  • GENERIC_TIMEZONE is the zone schedule triggers use. Without it, "every day at 09:00" means 09:00 UTC.
  • N8N_ENCRYPTION_KEY encrypts every stored credential. If you do not set it, n8n generates one and keeps it in a config file inside the data volume, where it is easy to lose. Setting it yourself means you can store a copy in your password manager today. Without that key, a restored database has workflows and no usable credentials.

Tip: on an instance that already exists, never invent a new key. Read the existing one with docker compose exec n8n cat /home/node/.n8n/config and put that value in .env. A different key makes every saved credential unreadable.

Caddy as the reverse proxy

Create /opt/n8n/Caddyfile:

n8n.example.com {
    reverse_proxy n8n:5678
}

That is the whole configuration. Caddy obtains a certificate for the name, renews it, redirects HTTP to HTTPS and passes through the WebSocket connection the editor needs. The certificate lives in the caddy_data volume, so it survives restarts.

Start everything:

bash
cd /opt/n8n
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs -f caddy

Within a minute the Caddy log reports a certificate obtained. Open https://n8n.example.com and create the owner account straight away, since the first person to load the page gets that screen.

Our Cloud n8n VPS is delivered with n8n already running in Docker under /opt/n8n on port 5678, so there you add the Caddy service, the domain variables and the DNS record, and remove the published port once the proxy works.

Updating

bash
cd /opt/n8n
sudo docker compose pull
sudo docker compose up -d
sudo docker image prune -f

Tip: take a backup first, and read the release notes before a major version. To control when upgrades happen, add a version tag from the n8n release notes after the image name. With no tag you get whatever is newest when you pull.

Backups

Two layers. The volume archive is the whole instance: database, settings, community nodes. The JSON exports are small and readable.

Create /opt/n8n/backup.sh:

bash
#!/bin/bash
set -euo pipefail
cd /opt/n8n
STAMP=$(date +%F)

# 1. The whole data volume, with n8n stopped so the database is consistent
trap 'docker compose start n8n' EXIT
docker compose stop n8n
docker run --rm -v n8n_n8n_data:/data:ro -v /opt/n8n/backups:/backup alpine \
  tar czf /backup/n8n-data-$STAMP.tar.gz -C /data .
docker compose start n8n

# 2. Workflows and credentials as JSON, using the n8n CLI in the container
docker compose exec -T n8n n8n export:workflow --all --output=/backups/workflows-$STAMP.json
docker compose exec -T n8n n8n export:credentials --all --output=/backups/credentials-$STAMP.json

# 3. Keep 14 days locally
find /opt/n8n/backups -type f -mtime +14 -delete

The volume name is the project directory plus the volume key, so n8n_n8n_data here; confirm with docker volume ls. The credentials export stays encrypted with your key, which is what you want in a file at rest. The stop and start costs a few seconds of downtime, which is why it runs at night, and the trap line starts n8n again even if the archive step fails. On an instance with no workflows yet, the export step reports an error; that is expected.

Schedule it as root (cron syntax):

bash
sudo chmod 700 /opt/n8n/backup.sh
sudo crontab -e
15 3 * * * /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1

Copy them off the server

A backup on the same disk does not survive losing the server. Pull the directory from another machine that has SSH access:

bash
rsync -az deploy@203.0.113.10:/opt/n8n/backups/ ~/n8n-backups/

Keep .env, docker-compose.yml and the Caddyfile with the backups, and the encryption key in a password manager as well. A provider snapshot is a useful extra layer, not a substitute for a copy you hold.

Test the restore

On a second server or a local machine with Docker, in a directory also named n8n so the volume name matches:

bash
mkdir -p ~/restore/n8n && cd ~/restore/n8n
# copy docker-compose.yml, .env, Caddyfile and one n8n-data archive here
mkdir backups
docker compose up --no-start
docker run --rm -v n8n_n8n_data:/data -v "$PWD":/backup alpine \
  tar xzf /backup/n8n-data-2026-10-22.tar.gz -C /data
docker compose up -d n8n
docker compose logs --tail 30 n8n

up --no-start creates the empty volume, the tar fills it, and starting only the n8n service avoids Caddy asking for a certificate on a machine the domain does not point to. To look inside, edit the test copy of the compose file before the last up: add ports: ["127.0.0.1:5678:5678"] to the n8n service and set N8N_SECURE_COOKIE=false, for the test only, then open http://localhost:5678. Log in, open a credential and run a workflow that uses it. If the credential works, the key and the data match and the backup is real.

To bring workflows back from the JSON export on a running instance instead (workflows with the same ID are overwritten):

bash
docker compose exec -T n8n n8n import:workflow --input=/backups/workflows-2026-10-22.json

Further options, including PostgreSQL and queue mode, are in the n8n hosting documentation.

#n8n#docker#caddy#https#backups#self-hosting

Keep reading

More from n8n & Automation

All guides

n8n & Automation

n8n Cloud vs Self-Hosted: Costs, Limits and When to Switch

n8n Cloud bills by executions, self-hosted n8n bills by server and your time. Limits, GDPR, upkeep, licence and a checklist for when to switch.

8 min read →

n8n & Automation

n8n Queue Mode Explained: Scaling with Redis and Workers

When n8n queue mode is worth it, how Redis, PostgreSQL and workers fit together, a working Docker Compose file, and fixes for the usual errors.

9 min read →

DirectAdmin

One Panel for Every Client Site: Managing 50 Websites with DirectAdmin

A working structure for 50 client sites on a DirectAdmin reseller account: one user per client, packages, login-as, PHP, SSL, backups and heavy accounts.

6 min read →