DevOps

Deploying n8n on Kubernetes with CloudNativePG, PVCs, and a ClusterIP Service

A practical walkthrough for running n8n on Kubernetes with Flux GitOps: wiring ConfigMaps, SOPS-managed secrets, CloudNativePG database credentials, persistent storage, and a ClusterIP Service behind Cloudflare Tunnel.

#DevOps #Kubernetes #n8n #FluxCD #GitOps #CloudNativePG #PostgreSQL #SOPS #Cloudflare Tunnel #Homelab

Running n8n in Kubernetes is not just “make a Deployment and expose a port.”

The working setup has a few moving parts:

  • n8n needs normal environment configuration from a ConfigMap
  • sensitive values need to come from Kubernetes Secrets
  • PostgreSQL credentials are generated by CloudNativePG
  • /home/node/.n8n still needs persistent storage
  • the Service only needs to be internal because Cloudflare Tunnel handles public traffic

This post walks through the Deployment and Service layer I used for my homelab n8n setup.

Setup context: Kubernetes homelab, Flux GitOps, CloudNativePG for PostgreSQL, SOPS + age for encrypted secrets, Cloudflare Tunnel for public access.


Architecture Diagram

Use the main page diagram above as the visual reference for the traffic path.

The core traffic path is:

Internet User
  ↓
Cloudflare DNS
  ↓
Cloudflare Tunnel
  ↓
cloudflared Pod
  ↓
n8n Service
  ↓
n8n Pod
  ↓
n8n-postgres-rw Service
  ↓
CloudNativePG PostgreSQL Pod
  ↓
PostgreSQL PVC

The n8n pod also mounts its own n8n-data PVC at /home/node/.n8n.


The Mental Model

I split the app into two GitOps layers:

automation-configs
  ├── namespace
  ├── ConfigMap
  ├── encrypted Secret
  ├── PVC
  └── CloudNativePG PostgreSQL Cluster

automation-controllers
  ├── n8n Deployment
  └── n8n Service

The Deployment should not create the database, Secret, ConfigMap, or PVC.

It should only consume them.

That distinction matters because Flux needs to apply things in the right order:

automation-configs
  ↓
automation-controllers

If the n8n pod starts before the ConfigMap, Secrets, PVC, or PostgreSQL service exist, it can fail with CreateContainerConfigError, CrashLoopBackOff, or a database connection error.


Repository Layout

The Deployment and Service live in the controller layer:

automation/controllers/base/n8n/
├── kustomization.yaml
├── n8n.yaml
└── service.yaml

The staging overlay references the base:

automation/controllers/staging/n8n/
└── kustomization.yaml

The base Kustomization is intentionally small:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - n8n.yaml
  - service.yaml

And the staging Kustomization can simply point at the base:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base/n8n

The n8n Deployment

Here is the final Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: n8n
  namespace: automation-n8n
  labels:
    app: n8n
spec:
  replicas: 1

  selector:
    matchLabels:
      app: n8n

  template:
    metadata:
      labels:
        app: n8n

    spec:
      securityContext:
        fsGroup: 1000

      initContainers:
        - name: fix-n8n-data-permissions
          image: busybox:1.36
          command:
            - sh
            - -c
            - chown -R 1000:1000 /home/node/.n8n
          volumeMounts:
            - name: n8n-data
              mountPath: /home/node/.n8n

      containers:
        - name: n8n
          image: docker.n8n.io/n8nio/n8n
          ports:
            - name: http
              containerPort: 5678
              protocol: TCP

          securityContext:
            runAsUser: 1000
            runAsGroup: 1000

          env:
            - name: DB_POSTGRESDB_DATABASE
              valueFrom:
                secretKeyRef:
                  name: n8n-postgres-app
                  key: dbname

            - name: DB_POSTGRESDB_HOST
              valueFrom:
                secretKeyRef:
                  name: n8n-postgres-app
                  key: host

            - name: DB_POSTGRESDB_PORT
              valueFrom:
                secretKeyRef:
                  name: n8n-postgres-app
                  key: port

            - name: DB_POSTGRESDB_USER
              valueFrom:
                secretKeyRef:
                  name: n8n-postgres-app
                  key: username

            - name: DB_POSTGRESDB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: n8n-postgres-app
                  key: password

            - name: N8N_ENCRYPTION_KEY
              valueFrom:
                secretKeyRef:
                  name: n8n-encrypt-key
                  key: N8N_ENCRYPTION_KEY

          envFrom:
            - configMapRef:
                name: n8n-config

          volumeMounts:
            - name: n8n-data
              mountPath: /home/node/.n8n

      volumes:
        - name: n8n-data
          persistentVolumeClaim:
            claimName: n8n-data

Let’s break down the parts that matter.


Why One Replica?

replicas: 1

For this deployment, one replica is intentional.

n8n is stateful, the /home/node/.n8n volume is mounted from a PVC, and this setup does not yet use n8n queue mode. Scaling n8n horizontally should be handled later with the right queue and worker architecture.

One pod is the correct starting point for a simple, reliable homelab deployment.


Loading Non-Secret Config from a ConfigMap

The Deployment loads normal runtime settings from n8n-config:

envFrom:
  - configMapRef:
      name: n8n-config

That ConfigMap contains values like:

  • GENERIC_TIMEZONE
  • TZ
  • N8N_EDITOR_BASE_URL
  • WEBHOOK_URL
  • N8N_HOST
  • N8N_PROTOCOL
  • N8N_PORT
  • N8N_PROXY_HOPS
  • DB_TYPE
  • DB_POSTGRESDB_SCHEMA

Using envFrom keeps the Deployment clean. Non-sensitive app settings live in the config layer, and the Deployment consumes them as environment variables.


Mapping CloudNativePG Secrets to n8n Variables

CloudNativePG generates an application Secret:

n8n-postgres-app

The keys in that Secret do not directly match the environment variable names n8n expects, so the Deployment maps them explicitly.

For example, the database name:

- name: DB_POSTGRESDB_DATABASE
  valueFrom:
    secretKeyRef:
      name: n8n-postgres-app
      key: dbname

The database host:

- name: DB_POSTGRESDB_HOST
  valueFrom:
    secretKeyRef:
      name: n8n-postgres-app
      key: host

The username:

- name: DB_POSTGRESDB_USER
  valueFrom:
    secretKeyRef:
      name: n8n-postgres-app
      key: username

And the password:

- name: DB_POSTGRESDB_PASSWORD
  valueFrom:
    secretKeyRef:
      name: n8n-postgres-app
      key: password

This is the right place for secretKeyRef: the Secret exists already, but the app needs specific variable names.


Keeping the n8n Encryption Key Out of the Deployment

n8n also needs a stable encryption key:

- name: N8N_ENCRYPTION_KEY
  valueFrom:
    secretKeyRef:
      name: n8n-encrypt-key
      key: N8N_ENCRYPTION_KEY

That Secret is managed separately and encrypted in Git with SOPS + age.

The important rule: do not bake the encryption key into the Deployment manifest. If the key changes unexpectedly, previously encrypted n8n credentials can become unusable.


Why n8n Still Needs a PVC

Even with PostgreSQL enabled, n8n still needs persistent storage at:

/home/node/.n8n

The Deployment mounts the PVC here:

volumeMounts:
  - name: n8n-data
    mountPath: /home/node/.n8n

And wires it to the n8n-data claim:

volumes:
  - name: n8n-data
    persistentVolumeClaim:
      claimName: n8n-data

This is separate from PostgreSQL storage. PostgreSQL stores workflow and execution data, but n8n still uses its application directory for runtime configuration and related files.

The Docker-style mental model:

n8n_data:/home/node/.n8n

becomes this Kubernetes model:

n8n-data PVC -> /home/node/.n8n

The PVC Permission Problem

This was the blocker that caused the pod to crash:

Error: EACCES: permission denied, open '/home/node/.n8n/config'

The PVC mounted successfully, but n8n could not write to it.

That usually means the mounted volume is owned by root, while the n8n process runs as the node user. In this image, that maps cleanly to:

UID: 1000
GID: 1000

The fix has three parts.

First, set the pod-level group:

securityContext:
  fsGroup: 1000

Then run the main container as the same user and group:

securityContext:
  runAsUser: 1000
  runAsGroup: 1000

Finally, use an initContainer to repair ownership before n8n starts:

initContainers:
  - name: fix-n8n-data-permissions
    image: busybox:1.36
    command:
      - sh
      - -c
      - chown -R 1000:1000 /home/node/.n8n
    volumeMounts:
      - name: n8n-data
        mountPath: /home/node/.n8n

After that, n8n can write to /home/node/.n8n/config and the pod can start normally.


The n8n Service

The Service is intentionally internal:

apiVersion: v1
kind: Service
metadata:
  name: n8n
  namespace: automation-n8n
  labels:
    app: n8n
spec:
  type: ClusterIP
  selector:
    app: n8n
  ports:
    - name: http
      protocol: TCP
      port: 5678
      targetPort: 5678

The selector must match the pod label:

Deployment pod label: app=n8n
Service selector:     app=n8n

If those do not match, the Service will have no endpoints.

Check that with:

kubectl get endpoints n8n -n automation-n8n

If the endpoints are empty:

kubectl describe svc n8n -n automation-n8n
kubectl get pods -n automation-n8n --show-labels

Why ClusterIP Is Enough

The Service does not need to be a NodePort or LoadBalancer.

External traffic flows through Cloudflare Tunnel:

Cloudflare edge
  ↓
cloudflared pod
  ↓
n8n ClusterIP Service
  ↓
n8n pod

The internal service URL is:

http://n8n.automation-n8n.svc.cluster.local:5678

And the cloudflared route points to that URL:

- hostname: n8n.example.com
  service: http://n8n.automation-n8n.svc.cluster.local:5678

If public access fails, prove the internal route first. Do not debug Cloudflare until the Kubernetes Service works from inside the cluster.


Verification Order

The verification order matters. I check from the inside out:

Flux
  ↓
Config resources
  ↓
Database resources
  ↓
n8n Deployment
  ↓
n8n Service
  ↓
Internal connectivity
  ↓
Cloudflare Tunnel
  ↓
Public URL

Start with Flux:

flux get kustomizations

The important entries should be ready:

infrastructure-controllers   Ready=True
automation-configs           Ready=True
automation-controllers       Ready=True

Then check the namespace:

kubectl get ns automation-n8n
kubectl get all -n automation-n8n

Check the config-layer resources:

kubectl get configmap n8n-config -n automation-n8n
kubectl get secret n8n-encrypt-key -n automation-n8n
kubectl get secret n8n-postgres-app -n automation-n8n
kubectl get pvc n8n-data -n automation-n8n

Check CloudNativePG:

kubectl get cluster -n automation-n8n
kubectl get pods -n automation-n8n | grep postgres
kubectl get svc -n automation-n8n | grep postgres

Check the n8n workload:

kubectl get deployment n8n -n automation-n8n
kubectl get pods -n automation-n8n -l app=n8n -o wide
kubectl logs -n automation-n8n deploy/n8n --tail=100

If the pod recently crashed, previous logs are usually more useful:

kubectl logs -n automation-n8n deploy/n8n --previous --tail=100

Then check the Service:

kubectl get svc n8n -n automation-n8n
kubectl get endpoints n8n -n automation-n8n

The endpoint should show a pod IP on port 5678.


Test Internal Connectivity First

Before opening the browser, test the internal service route from inside the cluster:

kubectl run curl-test \
  -n cloudflared \
  --rm -it \
  --image=curlimages/curl \
  --restart=Never \
  -- curl -I http://n8n.automation-n8n.svc.cluster.local:5678

If this fails, the problem is still inside Kubernetes.

Debug:

  • n8n pod
  • n8n logs
  • n8n Service
  • n8n endpoints

Only after internal connectivity works should you test the public hostname:

curl -I https://n8n.example.com

Quick Diagnosis Table

SymptomLikely MeaningFirst Place to Check
no matches for kind "Cluster"CloudNativePG CRD missinginfrastructure-controllers, CNPG CRDs
dependency ... not readyUpstream Flux layer failedParent Flux Kustomization
CreateContainerConfigErrorMissing ConfigMap or SecretPod describe output
CrashLoopBackOffContainer starts then exitsn8n logs
EACCES /home/node/.n8n/configPVC permission issueDeployment securityContext/initContainer
Service has no endpointsSelector mismatch or pod not readyService selector and pod labels
Browser shows 502Backend unhealthy or unreachablen8n pod, Service, endpoints
Browser shows 404cloudflared route mismatchcloudflared ConfigMap

What I Would Harden Next

This Deployment works, but it is not the final production-grade version.

The next improvements are:

  • pin the n8n image tag instead of using the floating default
  • add resource requests and limits
  • add readiness and liveness probes after testing them carefully
  • configure Cloudflare Access
  • define PostgreSQL and n8n PVC backup strategy
  • monitor pod restarts, PVC usage, and CloudNativePG health
  • consider NetworkPolicy
  • consider n8n queue mode before scaling beyond one pod

For example, the image should eventually become:

image: docker.n8n.io/n8nio/n8n:<version>

And resources should be explicit:

resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    cpu: 1000m
    memory: 1Gi

Probes are useful, but they should be tested before committing them. A bad probe can restart a healthy app and make the deployment less stable.


Final Mental Model

The final runtime flow is the same path shown in the architecture diagram: n8n loads its config and secrets, mounts the n8n-data PVC, connects to CloudNativePG through n8n-postgres-rw, and is exposed internally through the n8n Service before Cloudflare Tunnel routes public traffic to it.

The biggest lesson from this setup: Kubernetes can mount a volume successfully and the application can still fail if the file permissions are wrong.

For this n8n deployment, the final blocker was not Flux, Cloudflare, or PostgreSQL. It was the simple fact that the app could not write to /home/node/.n8n/config.

Once the PVC permissions were fixed and the Service had endpoints, Cloudflare Tunnel was just the final routing layer.

Comments