SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Why n8n keeps going offline on your VPS

Four different failures look like n8n going offline. Learn to tell the websocket banner, a restart loop, an out of memory kill and a dead schedule apart.

Why n8n keeps going offline: four failures, one symptom

"n8n keeps going offline" is one sentence covering four different failures, and each one needs a different fix. The editor shows a connection lost banner while the container is running normally. The container restarts by itself. The kernel kills the Node.js process for using too much memory. Or nothing is wrong with the process at all, and an active workflow simply never fires. Change the wrong setting and you will spend a weekend on a problem you never had.

So find out which failure you have before you touch any configuration. n8n runs as a single Node.js process, usually inside one Docker container, behind a reverse proxy that terminates TLS (transport layer security). Each of those layers breaks in its own way, and the browser reports all of them with the same message.

Diagnose in this order

Run these commands on the VPS (virtual private server) and read the values your own machine prints. Do not compare them with numbers from a forum thread. The values that matter here describe your box, not somebody else's.

docker ps -a --filter name=n8n
docker logs --tail 200 --timestamps n8n
docker inspect n8n | grep -iE 'Status|Running|RestartCount|OOMKilled|ExitCode'
docker stats --no-stream

The STATUS column from docker ps -a says how long the container has been in its current state. Compare that with the moment your problem started. If the container has been up since long before the banner appeared, then n8n never went offline. What broke is the connection between your browser and the backend, which is the websocket path covered in the next section.

RestartCount is how many times Docker has restarted this container. Write the number down, wait a minute, then read it again. A number that climbs while you watch is a restart loop, and the log lines from just before each restart carry the reason.

OOMKilled is a true or false flag. True means the Linux kernel killed the process because it went past a memory limit, either the container's own limit or the whole machine's. That single field separates a memory kill from every other kind of exit, which is why you read it before guessing.

ExitCode is whatever your container last exited with. You do not need to memorise what each code means. Read yours, then read the end of docker logs from the same timestamp. The log tail and the out of memory flag together tell you what happened, and either one alone can mislead you.

docker stats shows live memory use next to the limit in force. Leave it running in a second terminal, trigger the workflow that breaks things, and watch what the number does while the failure happens.


The connection lost banner is usually your reverse proxy

The n8n editor holds one long-lived push connection open to the backend so it can stream execution progress onto the canvas. By default that connection is a WebSocket, which is what N8N_PUSH_BACKEND selects, and its default value is websocket. A WebSocket starts as an ordinary HTTP request carrying the headers Connection: Upgrade and Upgrade: websocket. The server answers 101 Switching Protocols, and from then on both sides use the same TCP socket in both directions.

Two things break that, and both of them live in the proxy rather than in n8n. The proxy speaks HTTP/1.0 upstream or strips the upgrade headers, so the upgrade never happens and the editor reconnects forever. Or the upgrade succeeds and the proxy later closes the socket because it has been quiet, since a WebSocket with no messages on it looks exactly like an idle connection. In both cases the container is healthy. The banner is the browser telling you it lost its channel.

Confirm this in the browser before editing anything. Open developer tools, go to the Network tab, filter to WS, and reload the editor. The push request should reach 101 Switching Protocols and stay open. A push request that returns an ordinary status code, or one that reappears every few seconds, points at the proxy.

The nginx settings that keep the editor connected

nginx does not forward an upgrade unless you ask it to. proxy_pass talks HTTP/1.0 to the backend by default, and Connection and Upgrade are hop-by-hop headers that nginx removes on the way through. You have to put both back. The map block goes in the http context, not inside server.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server {
    listen 443 ssl;
    http2 on;
    server_name n8n.example.com;

    location / {
        proxy_pass http://127.0.0.1:5678;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $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_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
    }
}

proxy_read_timeout is the line people leave out. Its default is 60 seconds and it applies to an upgraded WebSocket too, so an editor tab left open on a quiet instance loses its connection about a minute after the last message crossed it. Raising it is what fixes the banner that greets you when you come back to a tab you left open.

sudo nginx -t && sudo systemctl reload nginx
sudo nginx -T | grep -iE 'proxy_http_version|upgrade|proxy_read_timeout'

nginx -T prints the whole running configuration rather than one file, so it proves your edit is actually loaded. A config that sits in a file no include line picks up is the reason a correct fix appears to do nothing.

Then tell n8n it is behind a proxy, because it builds URLs from these values.

environment:
  - N8N_HOST=n8n.example.com
  - N8N_PROTOCOL=https
  - N8N_PORT=5678
  - N8N_PROXY_HOPS=1
  - N8N_WEBHOOK_URL=https://n8n.example.com/

N8N_PROXY_HOPS defaults to 0, which means n8n treats the connecting address as the client address and ignores X-Forwarded-For. Set it to the number of proxies in front of the container. As of August 2026, N8N_WEBHOOK_URL is the current name and the older WEBHOOK_URL still works while printing a deprecation warning at startup.

Traefik forwards websockets, then times them out

Traefik forwards a WebSocket upgrade with no middleware and no extra labels, so a Traefik user who sees this banner is usually hitting a timeout instead of a missing header. The knobs are on the entryPoint. As of August 2026 in Traefik v3, idleTimeout defaults to 180 seconds and readTimeout defaults to 60 seconds.

entryPoints:
  websecure:
    address: ":443"
    transport:
      respondingTimeouts:
        readTimeout: 0
        idleTimeout: 3600s

Caddy handles the upgrade automatically in reverse_proxy and needs no directive for it. If you cannot change the proxy at all, because somebody else owns it, switch the push channel with N8N_PUSH_BACKEND=sse. SSE (server-sent events) is a normal HTTP response held open, so it survives a proxy that refuses upgrades, though an aggressive idle timeout still cuts it. Picking the proxy itself is a separate decision, and the nginx, Caddy and Traefik comparison covers what each one costs you to operate.

When the container really is restarting

If RestartCount climbs, the container is failing and Docker is bringing it back. Line the log timestamps up against each restart and read what came immediately before. Four causes cover almost all of it: a configuration error that stops startup, a database n8n cannot reach, a crash once it is running, and a memory kill.

Start with the volume, because permissions are the quiet one. The official image runs as the unprivileged user node and keeps its data in /home/node/.n8n. A bind mount created by root is not writable by that user, so the process dies at startup every single time and the restart policy hides it behind a loop.

docker compose config
docker run --rm -it --entrypoint sh docker.n8n.io/n8nio/n8n -c 'id'
docker exec n8n ls -ld /home/node/.n8n

A named volume avoids the problem entirely, since Docker creates it with the right ownership. If you need a bind mount, chown the host directory to the numeric user id the first command printed. Ownership mapping between host and container is worth understanding once, and the PUID and PGID explainer covers how these images decide who writes the files.

The out of memory kill that looks like a crash

There are two separate memory ceilings above an n8n process, and they fail differently. The container control group limit is enforced by the kernel: cross it and the process is killed immediately, with no chance to write anything, and OOMKilled reads true. The V8 heap limit is enforced inside Node.js: cross that and Node throws a heap error with a stack trace and exits on its own, so OOMKilled reads false. From the browser these look identical. From docker inspect they are one field apart.

Set the Node heap ceiling below the container limit. If the heap ceiling is the higher of the two, V8 keeps allocating right past the point where the kernel steps in, so its garbage collector never reaches its own limit and you always get the harsher failure with no log to read.

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: unless-stopped
    environment:
      - NODE_OPTIONS=--max-old-space-size=<MiB, below the container limit>
    deploy:
      resources:
        limits:
          memory: <your container limit>

Pick both numbers from what your VPS actually has, leaving room for the database, the proxy and the operating system. docker stats --no-stream prints current use beside the limit in force, so you can check that the limit you wrote is the limit Docker applied. How Compose memory limits are applied goes into which key wins when several are set.

Execution data is what grows underneath you

A single execution holds the output of every node while the run is in progress, and n8n then stores that data. Two consequences follow. The peak memory of one run is set by the largest batch of data you push through it, so a workflow that handles ten thousand rows at once is a different program from the same workflow handling two hundred at a time. And the stored copy keeps growing until something deletes it.

Pruning handles the second problem. As of August 2026 the defaults are pruning enabled, EXECUTIONS_DATA_MAX_AGE at 336 hours (14 days) and EXECUTIONS_DATA_PRUNE_MAX_COUNT at 10000. Those are generous for a small VPS running SQLite, where one file holds everything and the same process that serves the editor has to read and write it.

environment:
  - EXECUTIONS_DATA_PRUNE=true
  - EXECUTIONS_DATA_MAX_AGE=72
  - EXECUTIONS_DATA_PRUNE_MAX_COUNT=1000
  - EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
  - EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false

EXECUTIONS_DATA_SAVE_ON_SUCCESS=none is the aggressive setting. It keeps failed executions for debugging and throws away successful ones. Decide that on purpose, because a workflow that produced wrong output without raising an error then leaves you nothing to inspect. Pruning also marks rows deleted first and removes them on a later pass, and SQLite reuses freed pages rather than returning them, so the file on disk does not shrink the moment you change the setting.

To cut the peak instead of the stored total, move less data per run. Split large jobs into sub-workflows that return small results to the parent, batch with the Loop Over Items node, and keep whole datasets out of the Code node.

Binary files should not travel through memory

N8N_DEFAULT_BINARY_DATA_MODE defaults to default, which keeps binary data in the memory of the running execution. Every file a node downloads, and every copy handed to the next node, sits there until the run ends. One workflow fetching a few large attachments can push the process past a limit that ordinary JSON work never gets near, which is why the crash follows one specific workflow rather than the clock.

environment:
  - N8N_DEFAULT_BINARY_DATA_MODE=filesystem

With filesystem, binary data is written under N8N_BINARY_DATA_STORAGE_PATH, which lives inside the n8n user folder by default and therefore lands on the same volume as everything else. Check that the volume has room before you switch. N8N_PAYLOAD_SIZE_MAX sets the largest incoming webhook payload in MiB (mebibytes) and defaults to 16. Raising it lets bigger requests in, and that is a memory cost you are choosing to accept.

Whatever else shares the box competes for the same RAM. If the OOM kills started when you added a database container, running the database in Docker or on the host is the trade-off you are now making.

Restart policy, and coming back after a reboot

A container with no restart policy stays dead after it exits, and after the host reboots. restart: unless-stopped brings it back in both cases while still respecting a container you stopped by hand. restart: always also restarts one you stopped deliberately, once Docker next starts.

n8n serves a health endpoint, named by N8N_ENDPOINT_HEALTH, which defaults to healthz. Check it from the host first so you know the path is right on your instance.

curl -fsS http://127.0.0.1:5678/healthz
docker exec n8n which wget curl
sudo systemctl is-enabled docker

A healthcheck on its own restarts nothing. Compose marks the container unhealthy and stops there, so the healthcheck needs a restart policy or an external watcher beside it to have any effect. Writing a healthcheck that actually acts and making the stack start again after a reboot cover both halves.

The workflow that never fires while n8n is fine

This one produces no banner and no restart. The container is up, the editor works, and the run you expected is missing from the executions list. Four causes account for most of it.

  • The workflow is not active. A Schedule Trigger only runs on the production path, so testing it in the canvas schedules nothing.
  • The timezone is not yours. GENERIC_TIMEZONE defaults to America/New_York, so a schedule set for 09:00 fires at 09:00 in that zone until you set GENERIC_TIMEZONE and TZ to your own.
  • Downtime is not made up afterwards. Triggers are registered when n8n starts, so a schedule that came due while the container was restarting does not run late. The next run is the next due time after startup.
  • The workflow was deactivated for you. N8N_WORKFLOW_AUTODEACTIVATION_ENABLED is off by default, and when it is on a workflow that keeps crashing gets unpublished, after which it looks exactly like one nobody ever activated.

Open the executions list and filter to that workflow. An entry that failed is a workflow problem. No entry at all is a trigger problem, and the four causes above are where to look.

What to change first

  1. Read STATUS, RestartCount and OOMKilled on your own container before editing any file.
  2. If the container never went down, fix the proxy upgrade headers and the idle timeout.
  3. If OOMKilled is true, set a container limit you chose deliberately, put the Node heap ceiling below it, and switch binary data to filesystem.
  4. If nothing fired, check that the workflow is active and that the instance timezone is yours.

Most of this is configuration you set once and forget, on top of a working install. If you are still assembling that install, the n8n on Docker with HTTPS walkthrough is the base these settings belong in.

FAQ

Why does the n8n editor show a connection lost banner when the container is running?

The editor keeps a WebSocket open to stream execution progress. If your reverse proxy does not forward the Connection: Upgrade and Upgrade: websocket headers, or does not use HTTP/1.1 upstream, the upgrade never completes and the browser reconnects forever while n8n stays healthy. In nginx you need proxy_http_version 1.1 plus both proxy_set_header lines, and a proxy_read_timeout longer than the default 60 seconds so a quiet tab is not cut. Check the running config with sudo nginx -T, not the file you edited.

How do I tell an out of memory kill from an ordinary crash?

Run docker inspect n8n | grep -iE 'OOMKilled|ExitCode|RestartCount' and read the OOMKilled flag. True means the kernel killed the process for going over a memory limit, and there will be nothing useful in the container log because the process got no chance to write. False, with a heap error and a stack trace at the end of docker logs, means Node.js hit its own V8 heap ceiling and exited by itself. Set NODE_OPTIONS=--max-old-space-size below your container limit so you get the second failure, which is the one that leaves evidence.

Does pruning execution data free disk space right away?

No. EXECUTIONS_DATA_PRUNE marks old executions for deletion and a later pass removes them, on the schedule set by EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL. With SQLite the file also reuses freed pages instead of returning them to the filesystem, so the size on disk stays flat for a while after the rows are gone. Set EXECUTIONS_DATA_MAX_AGE and EXECUTIONS_DATA_PRUNE_MAX_COUNT to values that suit your box, then check again the next day rather than immediately.

Why did my scheduled workflow not run while n8n was restarting?

n8n registers triggers when the process starts, and it does not replay schedules that came due while it was down. A restart loop therefore produces silence rather than a burst of catch-up runs, and the next execution is the next due time after startup. If you need runs that cannot be missed, drive the workflow from an external caller hitting a webhook, so the retry logic lives outside n8n.

Will a healthcheck restart n8n when it stops responding?

Not by itself. A Compose healthcheck only marks the container healthy or unhealthy. Restarting is the job of the restart policy, so restart: unless-stopped is what brings the container back after it exits, and it also brings it back after a host reboot as long as the Docker service is enabled. Confirm that with sudo systemctl is-enabled docker. To act on unhealthy specifically, you need a watcher outside Docker that reads the status and restarts the service.