How I self-hosted n8n on my k8s cluster and what I learnt 2026-10-25T00 Coding

Self-Hosting n8n on Kubernetes

I recently went to n8n In The Loop, and I had a talk there titled Building a Self-Healing Research Agent with n8n, showcasing a workflow where Qdrant is used as a persistent memory layer to store and retrieve user feedback for the agent to avoid future mistakes. Of course, I had to have n8n running somewhere and, since I recently started running my own Kubernetes cluster with k3s on three of my local machines, I decided to host it there. In this blog post, I'll walk you through how I deployed n8n to my cluster, how I secured its access and shared it with other users, as well as lessons learnt in the process.

The Setup

My needs for the demo were pretty straightforward: the webhook triggering the workflow had to be reachable through the public network, and I had to be able to access the n8n editor to make edits, explain the workflow itself and showcase executions.

So, of course, I needed three main blocks:

While, for the first two, there was no decision to make, the third point was a bit harder. My k3s nodes are connected over Tailscale, and there's technically a way to expose Tailscale services to the public network, the funnel. While this would've meant almost zero configuration overhead, besides installing the Tailscale operator in my cluster, there were two main downsides:

For this reason, I decided to use Cloudflare Tunnels. Not only there's a generous free tier plan for them, but I could easily set up path-based access on the URLs I needed to be publicly exposed (webhooks and forms, in this case), and restrict everything else, as well as protect my own access to the editor exposing it through a second tunnel secured by Cloudflare One access-control policies. Using Cloudflare One on the free tier also allowed me for up to 50 users, perfect to invite myself as a test user from a different email, and my colleagues who had to have access to my n8n workspace to show the demo as well.

The only downside with Cloudflare was that I had to buy a new domain (I didn't have any spare ones), but I got a cheap one directly on Cloudflare and avoided DNS and nameservers migration headaches.

Postgres

To deploy Postgres, I decided to use the CloudNative Postgres (CNPG) operator to make high availability (HA) a little easier: CNPG manages replication (assigning a primary and its read replicas), failover/switchover (also with custom rules) and data persistence.

For my demo, I didn't need anything too advanced, but Postgres was the one layer that just needed to work to support the rest of the architecture, so it felt natural to go for a low-friction tool.

Installing it in my cluster was easy:

kubectl apply --server-side \
    -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml
kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager   

And, once the deployment of the CNPG cluster resources was complete, I set up Postgres. In my case, having three nodes, I decided to go with 3 replicas of the service (generally considered the minimum for HA).

# Requires the CloudNativePG operator (see README commands).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: n8n-pg
  namespace: n8n
spec:
  instances: 3
  postgresql:
    parameters:
      wal_keep_size: "1GB"
  storage:
    size: 5Gi
    storageClass: local-path   # k3s default; replication is done by Postgres
  bootstrap:
    initdb:
      database: n8n
      owner: n8n               # credentials land in Secret "n8n-pg-app"
  resources:
    requests: { cpu: 100m, memory: 256Mi }
    limits:   { memory: 1Gi }
  affinity:
    enablePodAntiAffinity: true
    podAntiAffinityType: required        # one instance per machine
    topologyKey: kubernetes.io/hostname
    tolerations:                         # fast failover when a node dies
      - key: node.kubernetes.io/unreachable
        operator: Exists
        effect: NoExecute
        tolerationSeconds: 30
      - key: node.kubernetes.io/not-ready
        operator: Exists
        effect: NoExecute
        tolerationSeconds: 30

Two interesting thing about this setup, that I didn't get right at first:

Besides some downtime due to connection issues between nodes, Postgres worked more than fine for my setup. I'd recommend adopting a similar strategy if you're looking to host your own workflows reliably, especially if you don't plan to travel on Deutsche Bahn trains with spotty connectivity :)

n8n

n8n itself had two level of set up difficulty.

The data layer was very easy: with Postgres running, it was just a matter of mapping environment variables to the secrets created by the CNPG cluster. The encryption key was also pretty easy to set up, it was just an openssl command.

Exposing the instance to traffic was kind of difficult: we're gonna go over this a bit more in details in the Cloudflare part, but of course n8n needs two access levels: one public (webhooks and forms, in my case), and one private (the editor). Also, as far as I know, the first person to sign up to an n8n workspace becomes its admin, and, at least for the community edition, nobody else can be promoted to administrator.

So after applying the following manifest:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: n8n
  namespace: n8n
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels: { app: n8n }
  template:
    metadata:
      labels: { app: n8n }
    spec:
      tolerations:
        - key: node.kubernetes.io/unreachable
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 30
        - key: node.kubernetes.io/not-ready
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 30
      securityContext:
        fsGroup: 1000
      containers:
        - name: n8n
          image: docker.n8n.io/n8nio/n8n:2.42.6
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 5678
          env:
            - name: N8N_ENCRYPTION_KEY
              valueFrom:
                secretKeyRef: { name: n8n-secrets, key: N8N_ENCRYPTION_KEY }
            - name: DB_TYPE
              value: postgresdb
            - name: DB_POSTGRESDB_HOST
              value: n8n-pg-rw          # always points at the current primary
            - name: DB_POSTGRESDB_PORT
              value: "5432"
            - name: DB_POSTGRESDB_DATABASE
              valueFrom:
                secretKeyRef: { name: n8n-pg-app, key: dbname }
            - name: DB_POSTGRESDB_USER
              valueFrom:
                secretKeyRef: { name: n8n-pg-app, key: username }
            - name: DB_POSTGRESDB_PASSWORD
              valueFrom:
                secretKeyRef: { name: n8n-pg-app, key: password }
            - name: N8N_HOST
              value: <my-domain>
            - name: N8N_PROTOCOL
              value: https
            - name: WEBHOOK_URL
              value: https://<my-domain>
            - name: N8N_PROXY_HOPS
              value: "1"
            - name: N8N_EDITOR_BASE_URL
              value: https://<my-editor-subdomain>
            - name: GENERIC_TIMEZONE
              value: Europe/Berlin
            - name: EXECUTIONS_DATA_PRUNE
              value: "true"
            - name: EXECUTIONS_DATA_MAX_AGE
              value: "168"
          readinessProbe:
            httpGet: { path: /healthz, port: 5678 }
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 5678 }
            initialDelaySeconds: 60
            periodSeconds: 15
            failureThreshold: 4
          resources:
            requests: { cpu: 250m, memory: 512Mi }
            limits:   { memory: 2Gi }
          volumeMounts:
            - { name: data, mountPath: /home/node/.n8n }
      volumes:
        - name: data
          emptyDir: {}     # state lives in Postgres; encryption key comes from the Secret
---
apiVersion: v1
kind: Service
metadata:
  name: n8n
  namespace: n8n
spec:
  selector: { app: n8n }
  ports:
    - port: 5678
      targetPort: 5678

I connected to the editor by port-forwarding the n8n service locally on localhost:5678:

kubectl port-forward -n n8n service/n8n 5678

Once in, I signed up and made sure that the platform was working as expected: at this point, only Cloudflare was the missing piece between this setup and secure exposure to the public internet.

Cloudflare

Before deploying the Cloudflare Daemon to my cluster, I needed one thing: tunnels that could expose my Kubernetes services to the internet.

After installing cloudflared, creating the tunnels was a matter of just a few commands:

cloudflared tunnel login # log in to Cloudflare to link my account
cloudflared tunnel create n8n-demo # create the tunnel
cloudflared tunnel route dns n8n-demo n8n.<my-domain> # add the public route (webhooks, forms)
cloudflaared tunnel route dns n8n-demo editor.<my-domainn> # add the private route (editor)

One last, very important, step remained before deploying Cloudflare: protecting the editor route. In order to do so, I created a policy un “Zero Trust → Access Control”, allowing only me and my colleagues to access the space based on our email addresses. I activated the One-Time Pin login method, and then connected to the my editor route in the tunnel: now, even if someone outside my org visited the domain, they wouldn’t be able to log in without access to an authorized email. For the other route, the one that needed public access, the trick was simply to limit the accessible paths to the ones needed for webhooks and forms (see configuration below). Of course, to avoid someone just spamming the webhook of my deployed demo and costing me thousands of dollars in LLM tokens, I added authentication to the trigger endpoint before publishing.

Once everything was ready, I deployed the Cloudflare Daemon with this manifest:

apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared-config
  namespace: n8n
data:
  config.yaml: |
    tunnel: <YOUR_TUNNEL_ID>
    credentials-file: /etc/cloudflared/creds/credentials.json
    metrics: 0.0.0.0:2000
    protocol: http2
    no-autoupdate: true
    ingress:
      - hostname: editor.<my-domain>
        service: http://n8n.n8n.svc.cluster.local:5678
      - hostname: n8n.<my-domain>
        path: ^/(webhook|form|webhook-waiting|form-waiting)/
        service: http://n8n.n8n.svc.cluster.local:5678
      - service: http_status:404
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: n8n
spec:
  replicas: 3
  selector:
    matchLabels: { app: cloudflared }
  template:
    metadata:
      labels: { app: cloudflared }
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: { app: cloudflared }
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector:
                  matchLabels: { app: cloudflared }
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:2026.10.0
          imagePullPolicy: IfNotPresent
          args: ["tunnel", "--config", "/etc/cloudflared/config/config.yaml", "run"]
          readinessProbe:
            httpGet: { path: /ready, port: 2000 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /ready, port: 2000 }
            initialDelaySeconds: 30
            periodSeconds: 15
            failureThreshold: 6
          resources:
            requests: { cpu: 50m, memory: 64Mi }
          volumeMounts:
            - { name: config, mountPath: /etc/cloudflared/config, readOnly: true }
            - { name: creds,  mountPath: /etc/cloudflared/creds,  readOnly: true }
      volumes:
        - name: config
          configMap: { name: cloudflared-config }
        - name: creds
          secret: { secretName: cloudflared-creds }

And, once each worker was up, I tested the connectivity and everything was working great!

In the end, besides a few hiccups here and there due to loss of connectivity between nodes (mostly caused by spotty WiFi), this self-hosting experiment was more than successful: I demoed my workflow more than once at the Qdrant booth, and once also during my talk, and everything went smoothly, and I’m determined to keep using my k3s cluster to learn Kubernetes hands-on by self-hosting my own services: wish me luck!