Introduction

n8n is a workflow automation tool — think Zapier or Make, but you run it yourself, your data never leaves your server, and you aren’t paying per task. It speaks to hundreds of APIs out of the box and lets you wire up anything you can reach over HTTP. The catch is that you need somewhere to run it, and you need to put it behind a proper reverse proxy with TLS before you expose it to the open internet.

This guide walks through standing up a clean Ubuntu host with Docker Engine, then bringing up n8n behind Traefik with Let’s Encrypt certificates managed automatically. By the end you’ll have:

  • A working Docker Engine install with the official APT repository
  • Traefik fronting your server on ports 80 and 443, handling HTTPS redirects and certificate renewal
  • n8n running as a container, reachable only through Traefik at https://automation.example.com
  • Persistent volumes for both the certificate store and n8n’s workflow data

Prerequisites:

  • A Linux server (this guide uses Ubuntu; any recent LTS works) with a public IP
  • A domain name you control, with an A record pointing a subdomain at the server’s IP
  • Root or sudo access
  • Ports 80 and 443 open on the host firewall and any upstream network
  • Basic comfort at the command line

One note before you start: this is an educational walkthrough of the setup I run. Test it in a non-production environment first, make sure you understand what each line does, and don’t point a production domain at it until you’ve verified everything works.


Architecture Overview

Two containers, orchestrated by Docker Compose:

  • Traefik is a reverse proxy. It listens on 80 and 443, terminates TLS, and routes incoming requests to whichever container matches the hostname. It also talks to Let’s Encrypt for you and rotates certificates automatically before they expire.
  • n8n is the workflow engine. It listens on 5678, but only on 127.0.0.1 — the only way in from outside is through Traefik. This is deliberate. It means nothing on the public internet can hit n8n directly; everything has to go through the proxy first.

The two containers talk to each other over Docker’s default bridge network. Traefik discovers n8n by reading labels off the n8n container via the Docker socket. Data persists in two named volumes (traefik_data for certificates, n8n_data for workflows) plus one bind mount (./local-files) for files you want to pass in and out of workflows.


Step 1: Clean Up Any Previous Docker Install

If this is a fresh server, skip ahead. If you’ve installed Docker before — the Snap version, the distro package, something you grabbed off a gist — you want to remove it first. Mixing Docker packages from different sources is a reliable way to spend an afternoon debugging something that has nothing to do with your actual problem.

sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)

What this does: dpkg --get-selections asks the package database which of those old Docker-adjacent packages are installed. cut -f1 keeps just the package names, and apt remove uninstalls whatever’s there. If none are installed, the command is a no-op, which is fine.


Step 2: Add the Official Docker APT Repository

You want Docker Engine from Docker’s own repository, not the distro’s. The distro version lags behind and sometimes ships with quirks that bite you later. Docker publishes a signing key and a repository definition; you install the key, tell APT about the repository, and let APT handle updates going forward.

sudo apt update
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

What this does: Refreshes APT’s package index, creates a directory for third-party signing keys with sane permissions, downloads Docker’s GPG key into it, and makes it world-readable so APT can use it to verify signatures on the Docker packages.

Now tell APT where the repository lives:

sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF

What this does: Writes a deb822-format sources file pointing at Docker’s Ubuntu repository. The inline shell snippet sources /etc/os-release and picks the Ubuntu codename (noble, jammy, etc.) automatically, so the same command works across releases. Signed-By ties the repository to the key you just installed — without that, APT won’t trust the packages.


Step 3: Install Docker Engine and Compose

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What this does: Refreshes the package index so APT sees the new repository, then installs Docker Engine (docker-ce), the CLI (docker-ce-cli), the container runtime (containerd.io), the build plugin, and the Compose v2 plugin. Compose v2 is a plugin now, invoked as docker compose (two words) rather than the old docker-compose (hyphenated) binary.

Verify the install:

sudo systemctl status docker
docker --version
docker compose version

What this does: Confirms the daemon is running, that the client is installed, and that the Compose plugin is available. If any of these fail, stop here and fix it — the rest of this guide assumes all three work.


Step 4: Create the Project Directory and .env File

Everything lives in one directory. Keeping configuration in an .env file instead of hardcoding values in the Compose file makes it easier to move this setup between hosts or share the Compose file without leaking your domain and email.

mkdir n8n-compose
cd n8n-compose
nano .env

Paste this in, replacing the values with your own:

DOMAIN_NAME=example.com
SUBDOMAIN=automation
GENERIC_TIMEZONE=America/New_York
SSL_EMAIL=you@example.com

What this does: Defines four variables that the Compose file will substitute in at runtime. SUBDOMAIN plus DOMAIN_NAME is the hostname Traefik will route to n8n — in this example, automation.example.com. That subdomain needs a real A record pointing at your server’s public IP before you start the stack, because Let’s Encrypt will verify it during the TLS challenge. SSL_EMAIL is what Let’s Encrypt uses to contact you about expiring certificates. GENERIC_TIMEZONE controls how n8n schedules cron-style workflows — get this wrong and your “run at 9 AM” trigger fires at the wrong time.

Create the local-files directory and open the Compose file:

mkdir local-files
nano compose.yaml

What this does: local-files is a bind-mounted directory that shows up inside the n8n container at /files. Workflows can read and write here — useful for dropping in a CSV, having n8n process it, and picking up the output.


Step 5: Write the Compose File

services:
  traefik:
    image: "traefik"
    restart: always
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
      - "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}"
      - "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - traefik_data:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
 
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: always
    ports:
      - "127.0.0.1:5678:5678"
    labels:
      - traefik.enable=true
      - traefik.http.routers.n8n.rule=Host(`${SUBDOMAIN}.${DOMAIN_NAME}`)
      - traefik.http.routers.n8n.tls=true
      - traefik.http.routers.n8n.entrypoints=web,websecure
      - traefik.http.routers.n8n.tls.certresolver=mytlschallenge
      - traefik.http.middlewares.n8n.headers.SSLRedirect=true
      - traefik.http.middlewares.n8n.headers.STSSeconds=315360000
      - traefik.http.middlewares.n8n.headers.browserXSSFilter=true
      - traefik.http.middlewares.n8n.headers.contentTypeNosniff=true
      - traefik.http.middlewares.n8n.headers.forceSTSHeader=true
      - traefik.http.middlewares.n8n.headers.SSLHost=${DOMAIN_NAME}
      - traefik.http.middlewares.n8n.headers.STSIncludeSubdomains=true
      - traefik.http.middlewares.n8n.headers.STSPreload=true
      - traefik.http.routers.n8n.middlewares=n8n@docker
    environment:
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_RUNNERS_ENABLED=true
      - NODE_ENV=production
      - WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
    volumes:
      - n8n_data:/home/node/.n8n
      - ./local-files:/files
 
volumes:
  n8n_data:
  traefik_data:

That is a lot of YAML. Walk through it in pieces.

Traefik command arguments:

  • --api.insecure=true exposes Traefik’s own dashboard. Convenient for debugging; you should either disable this or lock it down before the host is on an untrusted network.
  • --providers.docker=true tells Traefik to watch the Docker socket and configure itself from container labels.
  • --providers.docker.exposedbydefault=false means containers are opt-in: only ones with traefik.enable=true get routed. This is the right default — you don’t want a random container you start tomorrow suddenly being reachable from the internet.
  • The web entrypoint listens on 80 and immediately redirects to websecure on 443. Anyone who types http:// gets bumped to https:// automatically.
  • mytlschallenge is the name given to the Let’s Encrypt resolver. tlschallenge=true uses the ACME TLS-ALPN-01 challenge, which works entirely on port 443 — no need to keep port 80 open for HTTP challenges. storage=/letsencrypt/acme.json puts the issued certificate inside the traefik_data volume so it survives container restarts.

Traefik volumes:

  • traefik_data:/letsencrypt is the named volume that holds acme.json. Losing this file means Let’s Encrypt has to re-issue, and they rate-limit that, so keep it safe.
  • /var/run/docker.sock:/var/run/docker.sock:ro is how Traefik reads container labels. Mounting the Docker socket into a container is effectively giving it root on the host — that’s why this mount is read-only (:ro), and why you shouldn’t add extra containers to this compose file without thinking about whether they should have that level of trust.

n8n ports:

"127.0.0.1:5678:5678" binds n8n’s port 5678 only to the host’s loopback interface, not the public interface. That means the only way to reach n8n is through Traefik. If you ever see 0.0.0.0:5678 in your config, you’ve just opened n8n directly to the internet with no TLS and no auth in front of it — don’t do that.

n8n labels:

This is where Traefik is told about n8n. The router.n8n.rule says “match requests whose Host header is automation.example.com.” tls=true plus the certresolver hooks up Let’s Encrypt. The middlewares.n8n.headers.* labels attach a bundle of security headers — HSTS, content-type sniffing protection, X-XSS-Protection — to every response. STSSeconds=315360000 is ten years; browsers will remember to only talk to this host over HTTPS for that long. That’s a strong commitment, and it’s appropriate here because you’re never going to take this host off TLS.

n8n environment:

  • N8N_HOST, N8N_PORT, N8N_PROTOCOL, and WEBHOOK_URL tell n8n what its public URL is. Webhooks are the big one — n8n generates URLs for incoming webhooks based on these values, and if they’re wrong, every webhook you create will have the wrong URL baked into it.
  • N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true makes n8n refuse to start if its settings file has overly permissive permissions. A small thing that catches a real mistake.
  • N8N_RUNNERS_ENABLED=true turns on task runners, the newer execution model. This is the current recommended setting and will become the only option in future releases.
  • NODE_ENV=production quiets verbose logging and turns on Node’s production optimizations.
  • TZ and GENERIC_TIMEZONE together make sure both the container’s system clock and n8n’s internal scheduler agree on what time it is.

volumes:

  • n8n_data:/home/node/.n8n persists your workflows, credentials, and execution history. This is the one you absolutely want to back up.
  • ./local-files:/files is the bind mount for exchanging files with workflows.

Step 6: Bring It Up

sudo docker compose up -d

What this does: up creates and starts both containers. -d detaches — the stack keeps running after you close the terminal. First boot, Traefik will reach out to Let’s Encrypt and request a certificate for your subdomain via TLS-ALPN-01. If the DNS is wrong or the ports aren’t reachable from outside, this will fail silently into acme.json with an error. Tail the logs to watch it happen:

sudo docker compose logs -f traefik

You should see Traefik log that it’s obtained a certificate within a minute or so. If it retries or throws an error, stop the stack, check that your A record actually resolves to your server’s public IP (dig +short automation.example.com), and that nothing else is squatting on ports 80 and 443 (sudo ss -tlnp | grep -E ':80|:443').

Once the certificate is issued, open https://automation.example.com in a browser. You’ll land on n8n’s initial setup screen, where you’ll create the owner account. Do that immediately — until you do, n8n is unauthenticated.


Step 7: Back Up the State You Can’t Afford to Lose

Two things matter after the stack is running: the acme.json file inside traefik_data, and everything inside n8n_data. A simple backup script that stops the stack, tars both volumes, and restarts is enough for a small deployment. If you’re already running automated offsite backups of /var/lib/docker/volumes/ (or wherever Docker stores volumes on your setup), you’re covered.

The local-files directory is just on disk under your n8n-compose folder, so it’s trivially included in any directory-level backup.


Closing Thoughts

What you have now is a self-hosted automation platform that you own, running on a host you control, behind a reverse proxy with valid TLS that renews itself. No monthly per-task fee, no data walking out the door to someone else’s servers, and no vendor lock-in on the workflows you build.

Two things worth remembering. First, n8n is powerful, and self-hosted n8n is more powerful still — it can reach anything on your network. Treat the owner account credentials accordingly and consider running the stack on a host that doesn’t have line of sight to things it shouldn’t. Second, keep it updated. docker compose pull && docker compose up -d on a regular cadence pulls in new n8n releases and the latest Traefik patch versions. Check the n8n changelog before major version bumps, because breaking changes do happen.