<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Nuvits]]></title><description><![CDATA[Notes on VPS hosting, deployment and AI agents that operate servers, from the team behind Nuvits.]]></description><link>https://nuvits.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 09:26:41 GMT</lastBuildDate><atom:link href="https://nuvits.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to install n8n with Docker on a VPS]]></title><description><![CDATA[Run n8n in production on an Ubuntu 24.04 VPS with Docker Compose, PostgreSQL, an external task runner and automatic HTTPS from Caddy.]]></description><link>https://nuvits.hashnode.dev/install-n8n-docker-vps</link><guid isPermaLink="true">https://nuvits.hashnode.dev/install-n8n-docker-vps</guid><category><![CDATA[n8n]]></category><category><![CDATA[Docker]]></category><category><![CDATA[self-hosted]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Fernando Chagas]]></dc:creator><pubDate>Wed, 11 Nov 2026 13:09:10 GMT</pubDate><content:encoded><![CDATA[<p>This guide sets up self-hosted n8n for light production use on an Ubuntu 24.04 VPS: n8n backed by PostgreSQL, the task runner in its own container (which is what the n8n docs recommend for production), and Caddy in front to issue and renew the HTTPS certificate. The whole stack runs on Docker Compose from three files, and the guide also covers updates and backups.</p>
<h2>What you need</h2>
<ul>
<li>A VPS running 64-bit Ubuntu 24.04, with SSH access as a user that has <code>sudo</code>.</li>
<li>A domain where you can create DNS records. The examples use <code>n8n.example.com</code>.</li>
<li>An email address for the certificate's ACME account.</li>
</ul>
<p>The n8n docs don't set a hardware minimum for this exact setup. The official Docker Compose guide asks for 2 vCPUs and 4 GB of RAM once you add the n8n Assistant sandbox. This stack runs four containers (Caddy, n8n, the runner and PostgreSQL), so treat 2 vCPUs and 4 GB as the floor. With 4 vCPUs and 8 GB you have room for executions that move more data and for the database to grow.</p>
<p>If you just want to try n8n, <a href="https://nuvits.net/en">Nuvits</a> can also deliver the VPS with n8n preinstalled when you order it. That option runs n8n in a single container, without PostgreSQL or an external runner, and it only answers inside the VPS: you open the editor through an SSH tunnel. To receive webhooks from the internet with a domain and HTTPS, follow this guide.</p>
<h2>Prepare the server</h2>
<p>If your provider hands you the VPS with <code>root</code> access, create a user with <code>sudo</code> and copy your SSH key over:</p>
<pre><code class="language-bash">adduser deploy
usermod -aG sudo deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy ~/.ssh/authorized_keys /home/deploy/.ssh/
</code></pre>
<p>Open a second session as <code>deploy</code> and make sure you can log in before closing the <code>root</code> one. Then allow SSH, HTTP and HTTPS in ufw and turn the firewall on:</p>
<pre><code class="language-bash">sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enable
sudo ufw status
</code></pre>
<p><code>ufw enable</code> asks for confirmation because it may drop the SSH connection; with the port 22 rule already in place, answer <code>y</code>. The <code>443</code> rule has no protocol, so it covers both TCP and UDP. UDP is for HTTP/3, which Caddy also serves.</p>
<blockquote>
<p><strong>Docker bypasses ufw.</strong> A port published with <code>ports:</code> in Compose is reachable from outside even without a ufw rule, because Docker routes that traffic before it reaches the firewall's rules. That's why only Caddy publishes ports (80 and 443) in this guide. n8n and PostgreSQL stay on the internal Compose network.</p>
</blockquote>
<h2>Install Docker</h2>
<p>At Nuvits you can have Docker preinstalled when you order the VPS. If that's your case, skip ahead to <code>docker compose version</code> at the end of this section.</p>
<p>For servers, the Docker docs point to the official <code>apt</code> repository. First remove any unofficial packages that could conflict (on a fresh install, <code>apt</code> will usually report there's nothing to remove):</p>
<pre><code class="language-bash">sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
</code></pre>
<p>Add Docker's GPG key and repository:</p>
<pre><code class="language-bash">sudo apt update
sudo apt install ca-certificates curl
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

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

sudo apt update
</code></pre>
<p>Install Docker Engine and the Compose plugin, then check that everything works:</p>
<pre><code class="language-bash">sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo docker run hello-world
docker compose version
</code></pre>
<p>There's also the <code>get.docker.com</code> convenience script, but Docker's own docs recommend it only for testing and development, not production.</p>
<p>The commands in this guide use <code>sudo docker</code>. You can add your user to the <code>docker</code> group to drop the <code>sudo</code>, but the Docker docs warn that the group grants root-level privileges.</p>
<h2>Configure n8n</h2>
<p>Create a project directory. Compose uses its name as the prefix for volumes and containers:</p>
<pre><code class="language-bash">mkdir -p ~/n8n &amp;&amp; cd ~/n8n
</code></pre>
<h3>Secrets in .env</h3>
<p>Generate three random values: one for the encryption key, one for the runner token and one for the database password.</p>
<pre><code class="language-bash">openssl rand -hex 32
</code></pre>
<p>Run it three times and paste the results into <code>.env</code>:</p>
<pre><code class="language-ini">N8N_VERSION=2.47.4
N8N_DOMAIN=n8n.example.com
ACME_EMAIL=you@example.com
GENERIC_TIMEZONE=America/New_York

N8N_ENCRYPTION_KEY=paste-the-first-value-here
N8N_RUNNERS_AUTH_TOKEN=paste-the-second-value-here

POSTGRES_USER=n8n
POSTGRES_PASSWORD=paste-the-third-value-here
POSTGRES_DB=n8n
</code></pre>
<pre><code class="language-bash">chmod 600 .env
</code></pre>
<p><code>N8N_VERSION</code> pins both the n8n and the runner images, which must run the same version. Use the latest <code>stable</code> release listed on <a href="https://github.com/n8n-io/n8n/releases">n8n's releases page</a>; <code>2.47.4</code> was the stable version when this was written. Set <code>GENERIC_TIMEZONE</code> to your own IANA time zone name, such as <code>Europe/Berlin</code> or <code>America/Sao_Paulo</code>.</p>
<blockquote>
<p><strong>Keep the key safe.</strong> n8n uses <code>N8N_ENCRYPTION_KEY</code> to encrypt the credentials it stores in the database. Without it, a restored backup will load, but none of its credentials can be decrypted. Keep a copy of <code>.env</code> somewhere off the VPS, such as a password manager.</p>
</blockquote>
<h3>Caddyfile</h3>
<p>Caddy gets the certificate on its own as soon as it sees a domain name in the site address. <code>flush_interval -1</code> turns off response buffering, as in n8n's official Caddy example:</p>
<pre><code class="language-caddy">{
	email {$ACME_EMAIL}
}

{$N8N_DOMAIN} {
	reverse_proxy n8n:5678 {
		flush_interval -1
	}
}
</code></pre>
<p>Caddy fills in <code>{$N8N_DOMAIN}</code> and <code>{$ACME_EMAIL}</code> from the container's environment, which Compose sets from <code>.env</code>. <code>reverse_proxy</code> already sends the <code>X-Forwarded-For</code>, <code>X-Forwarded-Proto</code> and <code>X-Forwarded-Host</code> headers that n8n needs behind a proxy.</p>
<h3>docker-compose.yml</h3>
<pre><code class="language-yaml">services:
  caddy:
    image: caddy:2.11
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      - N8N_DOMAIN=${N8N_DOMAIN}
      - ACME_EMAIL=${ACME_EMAIL}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - n8n

  postgres:
    image: postgres:18
    restart: always
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
      - PGDATA=/var/lib/postgresql/data
    volumes:
      - db_storage:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

  n8n:
    image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
    restart: always
    environment:
      - N8N_HOST=${N8N_DOMAIN}
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - N8N_WEBHOOK_URL=https://${N8N_DOMAIN}/
      - N8N_PROXY_HOPS=1
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
      - 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_RUNNERS_MODE=external
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
    volumes:
      - n8n_data:/home/node/.n8n
    depends_on:
      postgres:
        condition: service_healthy

  n8n-runner:
    image: n8nio/runners:${N8N_VERSION}
    restart: always
    environment:
      - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
      - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
    depends_on:
      - n8n

volumes:
  caddy_data:
  caddy_config:
  db_storage:
  n8n_data:
</code></pre>
<p>A few things worth knowing about this file:</p>
<ul>
<li><code>N8N_WEBHOOK_URL</code> and <code>N8N_PROXY_HOPS=1</code> make n8n build webhook URLs with the public domain and trust Caddy's forwarded headers. Without them, the editor shows URLs with the internal port 5678. Before n8n 2.30.0 the variable was called <code>WEBHOOK_URL</code>, which is now deprecated.</li>
<li>The external runner executes Code node scripts outside the n8n process. Internal mode, the default, is deprecated, and the docs advise against it in production: code that escapes the sandbox gets the same access as n8n, credentials included.</li>
<li>Pinning <code>PGDATA</code> keeps Postgres 18's data inside the volume. Don't remove that line. Without it, Postgres 18 writes outside the volume and the database starts empty.</li>
<li>n8n keeps its volume at <code>/home/node/.n8n</code> even with PostgreSQL. The docs recommend keeping that folder persistent.</li>
<li>Neither n8n nor Postgres publishes a port. Caddy reaches n8n by its service name on the Compose network.</li>
</ul>
<h2>Point DNS and start</h2>
<p>In your domain's DNS settings, create an <code>A</code> record named <code>n8n</code> (or whatever subdomain you picked) pointing to the VPS's IPv4 address. Before starting the stack, check that the name already resolves to the right IP:</p>
<pre><code class="language-bash">dig +short n8n.example.com A
</code></pre>
<p>Once DNS resolves, bring the stack up:</p>
<pre><code class="language-bash">cd ~/n8n
sudo docker compose up -d
sudo docker compose ps
</code></pre>
<p>n8n only starts after Postgres passes its healthcheck. Follow the n8n and Caddy logs until the editor is up and the certificate has been issued:</p>
<pre><code class="language-bash">sudo docker compose logs -f n8n
sudo docker compose logs caddy
</code></pre>
<p>If the certificate doesn't come through, check that DNS has propagated and that ports 80 and 443 are also open in your provider's firewall, if it has one. Once that's fixed, run <code>sudo docker compose restart caddy</code>.</p>
<p>Then test from outside the VPS:</p>
<pre><code class="language-bash">curl -fsS https://n8n.example.com/healthz
</code></pre>
<p>Open <code>https://n8n.example.com</code> in your browser and create the owner account. To check webhooks, add a Webhook node to a workflow: its production URL should start with <code>https://n8n.example.com/webhook/</code>.</p>
<h2>Update and back up</h2>
<h3>Backups</h3>
<p>A full backup of n8n on PostgreSQL has two parts: the database, and the <code>.n8n</code> folder that holds the configuration and the encryption key. With the stack running:</p>
<pre><code class="language-bash">cd ~/n8n
set -a &amp;&amp; . ./.env &amp;&amp; set +a

sudo docker compose exec -T postgres pg_dumpall -U "$POSTGRES_USER" &gt; n8n-db-$(date +%F).sql
grep -c "PostgreSQL database cluster dump complete" n8n-db-$(date +%F).sql

sudo docker run --rm --volumes-from "$(sudo docker compose ps -q n8n)" -v "$PWD":/backup ubuntu:24.04 tar czf /backup/n8n-data-$(date +%F).tar.gz /home/node/.n8n
</code></pre>
<p>The <code>grep</code> must print <code>1</code>. If it prints <code>0</code>, the dump is incomplete. Then, from your own machine, copy both files and <code>.env</code> off the VPS:</p>
<pre><code class="language-bash">mkdir -p backups-n8n
scp 'deploy@VPS_IP:n8n/n8n-*' 'deploy@VPS_IP:n8n/.env' ./backups-n8n/
</code></pre>
<p>A backup that only lives on the VPS's disk is gone if you lose the VPS. To restore, stop n8n, put back the <code>.n8n</code> folder and the database, and start again with the same <code>.env</code>. The <a href="https://docs.n8n.io/deploy/host-n8n/keep-n8n-running/backup-and-restore">Back up and restore</a> page walks through each step.</p>
<blockquote>
<p><strong>Never run <code>docker compose down -v</code> in this directory.</strong> The <code>-v</code> also deletes the volumes, including <code>n8n_data</code> with the encryption key and <code>db_storage</code> with the database.</p>
</blockquote>
<h3>Updates</h3>
<p>n8n ships a new minor version most weeks. The docs suggest updating at least once a month, so you don't have to jump several versions at once, and reading the <a href="https://docs.n8n.io/changelog/release-notes-2.x">release notes</a> first. The routine:</p>
<ol>
<li>Take the backup above.</li>
<li>Change <code>N8N_VERSION</code> in <code>.env</code> to the new <code>stable</code> version. The runner follows along, since it uses the same variable.</li>
<li>Pull the images and recreate the containers:</li>
</ol>
<pre><code class="language-bash">cd ~/n8n
sudo docker compose pull
sudo docker compose down
sudo docker compose up -d
sudo docker compose logs -f n8n
</code></pre>
<p>n8n runs its database migrations on startup. Switching the tag back doesn't undo them, which is why the backup comes first. <code>docker compose pull</code> also fetches the latest Caddy 2.11 and PostgreSQL 18 images. Moving Postgres to a new major version (18 to 19, for example) takes a dump and restore, not just a tag change.</p>
<h2>Next steps</h2>
<ul>
<li>Turn on two-factor authentication for the owner account and set up SMTP for invites and password resets, using the <a href="https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/user-management-and-2fa">user management</a> variables.</li>
<li>Schedule the backup with <code>cron</code> and ship the copies somewhere else automatically.</li>
<li>Give n8n its own Postgres user without superuser rights, as in the official <a href="https://github.com/n8n-io/n8n-hosting/tree/main/docker-compose/withPostgres">withPostgres</a> example.</li>
<li>Read the <a href="https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/harden-task-runners">task runner hardening</a> guide if more people will be editing workflows on the instance.</li>
</ul>
<hr />
<p><em>Originally published on the <a href="https://nuvits.net/en/blog/install-n8n-docker-vps">Nuvits blog</a>.</em></p>
]]></content:encoded></item></channel></rss>