DEV Community

DeployHQ
DeployHQ

Posted on • Originally published at deployhq.com on

Self-Host n8n on a VPS: Docker, HTTPS, and Git-Based Updates

Most n8n self-hosting tutorials stop at docker compose up. That gets you a running instance, but it leaves a real gap: where do your workflows live? How do you upgrade without losing them? How do you roll back a bad change? Treat your n8n server like any other application — workflows in Git, infrastructure in Docker Compose, deploys triggered from main — and self-hosting becomes a maintainable habit instead of a fragile pet project.

This guide walks through standing up n8n on a single VPS with Docker, putting it behind HTTPS, and wiring it to a DeployHQ project so every change to your workflow repo updates the server. By the end you'll have a production-ready instance with backups, version control, and one-click rollback when something goes wrong.

What you'll build

  • n8n running in Docker on a single Ubuntu VPS
  • Postgres alongside it as the data store (not SQLite — more on that below)
  • Nginx + Let's Encrypt in front for HTTPS on your own domain
  • A Git repository that holds the docker-compose.yml, environment template, and an exportable JSON copy of every workflow
  • DeployHQ wiring the repo to the server, so a git push to main reconfigures the stack and re-imports workflows

By the end you'll be able to develop a workflow locally (or on a staging instance), export it, commit, push, and watch DeployHQ deploy the change with rollback available if it misbehaves.

Why self-host n8n at all?

n8n Cloud is good. So why bother with a VPS?

  • Cost predictability. A $5-10/month VPS handles thousands of runs per day. Cloud pricing scales by execution.
  • Data stays on your infrastructure. Webhooks, credentials, and execution logs never leave the box.
  • Custom nodes. You can npm install community nodes the cloud version doesn't ship.
  • No execution limits. Long-running flows that hit cloud step caps just run.

The trade-off is operational ownership — you patch the OS, renew the certificate, watch the disk. Most of that is one-time setup. The rest of this guide is the one-time setup, done properly.

Prerequisites

  • A VPS with Ubuntu 22.04 or 24.04 and SSH access — any of Hetzner, DigitalOcean, Vultr, Linode work fine
  • A domain you control with an A record pointing at the VPS IP
  • A DeployHQ account (free trial — sign-up link at the end)
  • A GitHub or GitLab repository
  • Docker installed locally for testing

Step 1: Provision the VPS

SSH in as root, install Docker, and set up a deploy user:

ssh root@your-vps-ip
curl -fsSL https://get.docker.com | sh

adduser deploy
usermod -aG docker deploy
mkdir -p /home/deploy/n8n
chown deploy:deploy /home/deploy/n8n

Enter fullscreen mode Exit fullscreen mode

From your local machine, copy your SSH key to the deploy user:

ssh-copy-id deploy@your-vps-ip

Enter fullscreen mode Exit fullscreen mode

Confirm passwordless login works:

ssh deploy@your-vps-ip "docker --version"

Enter fullscreen mode Exit fullscreen mode

You should see the installed Docker version. That's the account DeployHQ will use.

Step 2: The n8n Docker Compose stack

In your local repo, create docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: n8nio/n8n:1.74.0 # pin to a specific tag — don't use :latest in prod
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "127.0.0.1:5678:5678" # localhost only — Nginx will proxy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_PORT: 5432
      DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
      DB_POSTGRESDB_USER: ${POSTGRES_USER}
      DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
      N8N_HOST: ${N8N_HOST}
      N8N_PROTOCOL: https
      N8N_PORT: 5678
      WEBHOOK_URL: https://${N8N_HOST}/
      GENERIC_TIMEZONE: ${TIMEZONE:-Europe/London}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
    volumes:
      - n8n_data:/home/node/.n8n
      - ./workflows:/workflows:ro

volumes:
  postgres_data:
  n8n_data:

Enter fullscreen mode Exit fullscreen mode

Four things matter here:

  1. Postgres, not SQLite. SQLite is fine for kicking the tires. For anything you care about, Postgres handles concurrent executions, backups, and growth without complaint.
  2. N8N_ENCRYPTION_KEY is sacred. It encrypts the credentials n8n stores in the database. Change it and every credential breaks — you'll have to re-enter every API key, OAuth token, and SSH credential. Generate it once with openssl rand -hex 32, store it in DeployHQ as a config file, and never lose it.
  3. WEBHOOK_URL must be your public HTTPS URL. n8n bakes this into the URLs it gives webhook senders. If it's wrong, Stripe / GitHub / Slack will hit a dead address.
  4. Port 5678 is bound to 127.0.0.1. Don't expose n8n directly to the internet — put it behind Nginx with TLS. Anyone hitting port 5678 from outside gets nothing.

Create .env.example to commit to the repo (without secrets):

POSTGRES_USER=n8n
POSTGRES_PASSWORD=
POSTGRES_DB=n8n
N8N_HOST=workflows.yourdomain.com
N8N_ENCRYPTION_KEY=
TIMEZONE=Europe/London

Enter fullscreen mode Exit fullscreen mode

The real .env lives in DeployHQ's server config — never in the repo.

Step 3: Put Nginx in front with Let's Encrypt

On the VPS, install Nginx and Certbot:

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx

Enter fullscreen mode Exit fullscreen mode

Add a server block at /etc/nginx/sites-available/n8n:

server {
    server_name workflows.yourdomain.com;

    client_max_body_size 50M;

    location / {
        proxy_pass http://127.0.0.1:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 3600; # long-running executions
        proxy_send_timeout 3600;
    }

    listen 80;
}

Enter fullscreen mode Exit fullscreen mode

Enable it and run Certbot:

sudo ln -s /etc/nginx/sites-available/n8n /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d workflows.yourdomain.com

Enter fullscreen mode Exit fullscreen mode

The proxy_read_timeout 3600 is important. n8n executions can run for minutes when a workflow waits on an external API — without that, Nginx kills the connection and the run appears to fail.

Step 4: First run

Back on your local machine, commit the repo and push it to GitHub:

git init && git add . && git commit -m "Initial n8n stack"
git remote add origin git@github.com:you/n8n-stack.git
git push -u origin main

Enter fullscreen mode Exit fullscreen mode

For the very first deploy, SSH into the VPS, copy the .env over (DeployHQ will manage it from here on), and bring the stack up manually:

ssh deploy@your-vps-ip
cd /home/deploy/n8n
# (DeployHQ will populate docker-compose.yml on first deploy — for now scp it manually)
docker compose up -d

Enter fullscreen mode Exit fullscreen mode

Visit https://workflows.yourdomain.com, create the admin account, and you're live.

Step 5: Wire up Git-based deploys with DeployHQ

This is the part most n8n tutorials skip. In DeployHQ:

  1. Create a new project and point it at your repo. DeployHQ supports deploying directly from a GitHub repo to your server without you wiring webhooks by hand.
  2. Add the VPS as a server. Username deploy, port 22, deployment path /home/deploy/n8n. Use the SSH key you authorised in Step 1.
  3. Add the .env as a config file. In the server's config-files section, create /home/deploy/n8n/.env and paste the real values (Postgres password, N8N_ENCRYPTION_KEY, N8N_HOST). DeployHQ writes this file before every deploy — your secrets stay out of Git.
  4. Configure the deploy SSH command: bash cd /home/deploy/n8n && \ docker compose pull && \ docker compose up -d && \ docker compose exec -T n8n n8n import:workflow --input=/workflows/ --separate This pulls any new n8n image version, recreates containers with the new Compose config, and re-imports every workflow JSON from the mounted /workflows directory.
  5. Enable auto-deploy on push to main. Every merge now triggers a deploy.

Push a no-op change to trigger the first run. Watch the DeployHQ log — you'll see the repo transferred, the env file written, the SSH commands executed. The site shouldn't blink: docker compose up -d only recreates containers if something actually changed.

If a deploy ever breaks something, DeployHQ's one-click rollback restores the previous repo state and re-runs the deploy command — a single button, no manual Compose juggling.

Step 6: Workflows as code

This is the payoff for everything above. Instead of the workflow is whatever I last clicked in the UI, your repo becomes the source of truth.

The flow:

  1. Develop locally or on a staging instance. A spare n8n container on your laptop works fine.
  2. Export the workflow. In the n8n UI: open the workflow → menu → Download → save the JSON to workflows/your-workflow-name.json in your repo.
  3. Commit and push. bash git add workflows/your-workflow-name.json git commit -m "Add: Slack-to-Sheets sync workflow" git push
  4. DeployHQ deploys. The deploy command runs n8n import:workflow --input=/workflows/ --separate, which upserts the workflow into the database. Existing workflows with the same ID are updated; new ones are created.

A few practical notes:

  • One JSON file per workflow. The --separate flag tells n8n's importer to treat each file independently. Easier diffs in PRs.
  • Credentials are not in the JSON. n8n stores them encrypted in the database, referenced by ID. Set up credentials once in the UI; the workflow JSON just references them. This is why N8N_ENCRYPTION_KEY matters — drop it and the references become unusable.
  • The UI is now read-only in your head. Anyone making changes in production goes back to staging, exports, commits. Drift kills you otherwise.

The same Compose pattern works for sidecar services — drop a Python agent or a Postgres backup container into the same docker-compose.yml, and DeployHQ deploys all of it together.

Step 7: Upgrade n8n safely

n8n releases often. The temptation is to use n8nio/n8n:latest and let it ride. Don't.

With a pinned tag, upgrading becomes a one-line PR:

- image: n8nio/n8n:1.74.0
+ image: n8nio/n8n:1.78.0

Enter fullscreen mode Exit fullscreen mode

Push, DeployHQ deploys, the new image is pulled, the container recreated. If anything misbehaves, hit rollback and the previous tag is back in seconds. You get a git history of every n8n version that has ever run in production — handy when a workflow stops working and you need to find what changed.

Two upgrade hygiene tips:

  • Read the changelog before bumping a major. n8n occasionally deprecates nodes or changes execution semantics.
  • Snapshot Postgres before a major. A docker compose exec postgres pg_dump ... to a file in a backup directory takes seconds and saves hours.

For deploys in general, DeployHQ runs zero downtime deployments by default — when you eventually move to a multi-server setup, the rollout pattern stays the same.

Step 8: Backups

Two things to back up:

The Postgres database. Everything important — workflows, credentials, execution history — lives here.

docker compose exec -T postgres \
  pg_dump -U n8n -d n8n --no-owner --clean \
  > backup-$(date +%F).sql

Enter fullscreen mode Exit fullscreen mode

Drop a small script in your repo at scripts/backup.sh that runs this and uploads the file to S3 (or B2, or any object store). Cron it nightly on the VPS.

The n8n_data volume. Holds binary attachments and custom nodes. Tar it once a week to the same store:

docker run --rm -v n8n_n8n_data:/data -v $PWD:/backup alpine \
  tar czf /backup/n8n-data-$(date +%F).tar.gz -C /data .

Enter fullscreen mode Exit fullscreen mode

The workflows themselves are already in Git, so you don't need to back those up — that's the whole point of the workflows-as-code pattern.

Where to go from here

The pillar above gets you a single-node, production-ready n8n instance with version control and rollback. From here:


Ready to put n8n on infrastructure you control, with a Git-driven deploy pipeline behind it? Start a freeDeployHQ trial and wire your first push-to-deploy n8n stack in under twenty minutes.

Questions? Email us at support@deployhq.com or find us on X at @deployhq.

Top comments (0)