Small Business Automation Stack on One VPS: n8n and Baserow

Published on

Run n8n, Baserow and PostgreSQL behind Caddy on one VPS: size it from the docs, lock it down, back it up nightly, watch it from outside and assign the upkeep.

A technician holding a tablet in a dim data center aisle between server racks with teal and yellow cables

Leads arrive through a web form, someone copies them into a spreadsheet, and the follow-up email goes out when somebody remembers. Instead of a paid SaaS tool for every step, you want one server that runs the workflows and keeps the customer list. This guide builds it with n8n, Baserow, PostgreSQL and Caddy, sizes it from each project's docs, and ends with the part most setups skip: who keeps it running. Commands are for Ubuntu 24.04 and Debian 12.

Quick answer: Run four containers from one Compose file: n8n for workflows and forms, Baserow as the customer table, PostgreSQL 17 with a database and login per app, and Caddy, the only container with open ports, which handles the HTTPS certificates. Ubuntu needs 1 GB and Baserow 2 GB before n8n loads anything, so 4 GB is the floor and 8 GB the comfortable size. Claim both admin accounts at first start, copy nightly dumps and volume archives off the server, and watch both sites and the backup from a monitor elsewhere. Then name one person to update it monthly, and a deputy.

What Runs in the Stack

JobToolWhy this one
Workflowsn8nFree Community edition for internal business use
Web formsn8n Form Trigger or a Baserow formNo extra app to run
Customer tableBaserowOpen Source Edition under the MIT licence
DatabasePostgreSQL 17One server, a database and login per app
HTTPSCaddyGets and renews certificates by itself
Uptime checksUptime KumaRuns on another machine
Backupspg_dump, tar, resticCopies leave the server nightly

n8n's Form Trigger "generates the form web page for you to use" and runs a workflow per submission. In a Baserow form view, "their responses create new rows in your table", and visitors with the share link need no account. n8n's Baserow node creates, reads, updates and deletes rows.

Need deals and a sales pipeline instead of a table? Twenty, an open source CRM mostly under AGPLv3, runs a server, a worker, PostgreSQL and Redis, and its docs ask for "at least 2GB of RAM". This guide uses Baserow, which runs as one container.

How Big the VPS Must Be

PartWhat its docs say
Ubuntu Server 24.041 GB RAM minimum for cloud images, 3 GB or more suggested; 25 GB disk or more suggested
Baserow image"at minimum 2GB of RAM", according to Baserow's community FAQ (2023)
n8nNo minimum; memory follows the data your workflows load
PostgreSQL 17shared_buffers is "typically 128 megabytes" by default
CaddyNo figure published

n8n's Compose guide asks for at least 4 GB of RAM and 2 vCPUs, but that covers its AI sandbox, a Docker-in-Docker container this stack leaves out. n8n itself "doesn't restrict the amount of data each node can fetch and process", so one large export can need more RAM than the rest.

The system and Baserow alone take 3 GB, leaving 1 GB of a 4 GB plan for n8n and PostgreSQL. Our RAM guide explains why to stay under about three quarters of the RAM, which points to 8 GB. On 4 GB, add BASEROW_RUN_MINIMAL: "1" to the baserow environment, a setting Baserow offers "to lower the memory usage of Baserow in expense of performance". For disk, add your data, images and a few nights of backups to Ubuntu's 25 GB. On Space-Node, a Standard VPS-M has 4 vCores, 8 GB RAM and 80 GB NVMe; see the VPS plans for n8n and AI agents, and our guide on one VPS or several for when an app needs its own server.

Step 1: Prepare the Server

Log in with an SSH key and turn off password logins, as our SSH hardening guide shows. Ubuntu ships with ufw, and its unattended-upgrades is "installed by default" and installs security updates from the start. Debian may lack both; install them and switch the updates on:

sudo apt install ufw unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades

Then let SSH through the firewall before you turn it on:

sudo ufw allow 22/tcp
sudo ufw enable

Install Docker Engine and the Compose plugin with Docker's guide for Ubuntu or Debian. Then point two names at the server with A and AAAA records; this guide uses n8n.example.com and crm.example.com. Caddy needs those records and ports 80 and 443, "both required for the ACME HTTP challenge", before it can get certificates.

Step 2: Write the Compose File

Create the project folder and a .env file with a random value for every secret:

sudo mkdir -p /srv/automation/conf
cd /srv/automation
for v in POSTGRES_PASSWORD N8N_DB_PASSWORD BASEROW_DB_PASSWORD \
         N8N_ENCRYPTION_KEY BASEROW_SECRET_KEY BASEROW_JWT_SIGNING_KEY; do
  echo "$v=$(openssl rand -hex 32)"
done | sudo tee .env > /dev/null
sudo chmod 600 .env

Compose fills the ${...} values below from .env, and :? stops it when one is missing. Copy the file to your password manager now: n8n encrypts saved credentials with N8N_ENCRYPTION_KEY, and a restore without that key can't decrypt them, as our n8n backup guide shows. Then create /srv/automation/compose.yaml as root:

services:
  postgres:
    image: pgvector/pgvector:0.8.7-pg17
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  n8n:
    image: n8nio/n8n:2.45.7
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD:?}
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:?}
      N8N_HOST: n8n.example.com
      N8N_PROTOCOL: https
      N8N_PROXY_HOPS: "1"
      N8N_WEBHOOK_URL: https://n8n.example.com/
      GENERIC_TIMEZONE: Europe/Amsterdam
    volumes:
      - n8n-data:/home/node/.n8n

  baserow:
    image: baserow/baserow:2.4.1
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      BASEROW_PUBLIC_URL: https://crm.example.com
      DATABASE_HOST: postgres
      DATABASE_NAME: baserow
      DATABASE_USER: baserow
      DATABASE_PASSWORD: ${BASEROW_DB_PASSWORD:?}
      SECRET_KEY: ${BASEROW_SECRET_KEY:?}
      BASEROW_JWT_SIGNING_KEY: ${BASEROW_JWT_SIGNING_KEY:?}
    volumes:
      - baserow-data:/baserow/data

  caddy:
    image: caddy:2.11.7
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./conf:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config

volumes:
  pgdata:
  n8n-data:
  baserow-data:
  caddy_data:
  caddy_config:

Put the routing in /srv/automation/conf/Caddyfile; Caddy's image docs say to mount the folder, not the file:

n8n.example.com {
    reverse_proxy n8n:5678
}

crm.example.com {
    reverse_proxy baserow:80
}

Why it looks like this:

  • Only Caddy publishes ports. The others have no ports: entry, so only containers on the project network reach them, each "discoverable by its service name". Docker's published ports skip UFW, as our Docker and UFW guide explains, but here that only concerns 80 and 443. Caddy "keeps all managed certificates renewed" and redirects HTTP to HTTPS.
  • One PostgreSQL, two databases. n8n supports PostgreSQL 16 to 18. Baserow supports 14 or newer and lists the pgvector extension among its dependencies; its own Compose file uses the pgvector/pgvector image, which "adds pgvector to the Postgres image". On 17, mount the volume at /var/lib/postgresql/data: the image docs warn that a mount at /var/lib/postgresql loses the data when the container is recreated.
  • Baserow on the shared database. The DATABASE_ variables switch off the PostgreSQL inside the Baserow image, which its docs call "highly recommended for any production deployments". Inside the container it keeps serving plain HTTP on port 80 for Caddy. BASEROW_PUBLIC_URL must match the address in your browser.
  • n8n behind Caddy. N8N_PROXY_HOPS says one proxy sits in front, N8N_WEBHOOK_URL sets the public webhook address, and Caddy sends the three X-Forwarded- headers n8n asks for. GENERIC_TIMEZONE sets the time zone for Schedule triggers.

Step 3: Create the Databases and Claim the Admin Accounts

Start PostgreSQL on its own, show the two database passwords and open a shell as the superuser:

cd /srv/automation
sudo docker compose up -d postgres
sudo grep DB_PASSWORD .env
sudo docker compose exec postgres psql -U postgres

Create a login and a database per app with those passwords, and enable pgvector in Baserow's database, which the pgvector README says to do "once in each database where you want to use it":

CREATE ROLE n8n WITH LOGIN PASSWORD 'paste N8N_DB_PASSWORD here';
CREATE DATABASE n8n OWNER n8n;
CREATE ROLE baserow WITH LOGIN PASSWORD 'paste BASEROW_DB_PASSWORD here';
CREATE DATABASE baserow OWNER baserow;
\c baserow
CREATE EXTENSION vector;
\q

Owning its database lets each app create tables, because PostgreSQL 15 and newer give a database's owner its public schema; the superuser creates the extension because many extensions require one. Start the rest and follow Baserow's first start until it logs Baserow is now available at, then press Ctrl+C:

sudo docker compose up -d
sudo docker compose logs -f baserow

Open both addresses straight away: n8n shows a signup screen for its owner account, and Baserow gives staff rights to the first user who signs up, so whoever comes first runs the instance. In Baserow's admin Settings, turn off Allow creating new accounts; people you invite to a workspace can still join. For n8n logins, two-factor authentication and webhook protection, follow our n8n security guide.

Step 4: Connect n8n and Baserow

Connect them over their public HTTPS addresses:

  • n8n to Baserow: in Baserow, create a token under My Settings > Database tokens. In n8n, add a Baserow credential with that token and set Host to https://crm.example.com.
  • Baserow to n8n: start an n8n workflow with a Webhook node, set HTTP Method to POST and Authentication to Header auth with a header name and a long random value, and publish it. In Baserow, open the ••• menu of the table, choose Webhooks, select POST and the event for created rows, paste the production URL and add the same header.

The public addresses avoid two guards against internal requests: Baserow refuses webhooks to private addresses unless you set BASEROW_WEBHOOKS_ALLOW_PRIVATE_ADDRESS, which its docs call "a security risk", and n8n's SSRF protection, once turned on, blocks private ranges too. A first workflow: Form Trigger, Baserow Create a row, then an email to the lead.

Step 5: Back Up Every Night

Save this as /usr/local/sbin/automation-backup, with the Push URL from Step 6:

#!/bin/bash
set -euo pipefail
cd /srv/automation
B=/var/backups/automation
mkdir -p "$B" && chmod 700 "$B"

docker compose exec -T postgres pg_dumpall -U postgres --globals-only > "$B/globals.sql"
for db in n8n baserow; do
  docker compose exec -T postgres pg_dump -U postgres -Fc "$db" > "$B/$db.dump"
done

trap 'docker compose start n8n baserow' EXIT
docker compose stop n8n baserow
for v in n8n-data baserow-data; do
  docker run --rm -v "automation_$v:/data:ro" -v "$B:/backup" ubuntu:24.04 \
    tar czf "/backup/$v.tar.gz" -C /data .
done
trap - EXIT
docker compose start n8n baserow

tar czf "$B/project.tar.gz" -C /srv automation
curl -fsS -m 10 "https://status.example.net/api/push/YOUR_TOKEN?status=up&msg=OK" > /dev/null

pg_dump "makes consistent backups even if the database is being used concurrently", and pg_dumpall --globals-only adds the logins it leaves out. n8n and Baserow stop only while their volumes are archived, as Baserow's docs ask; Compose prefixes volume names with the folder name, hence automation_. The archives hold n8n's key, Baserow's uploads and .env, so the folder stays private. If a step fails, set -e stops the script before the ping and the monitor alerts you. Make it executable and schedule it for 03:30:

sudo chmod 700 /usr/local/sbin/automation-backup
echo '30 3 * * * root /usr/local/sbin/automation-backup' | sudo tee /etc/cron.d/automation-backup

Keep scheduled workflows out of that window, and copy /var/backups/automation off the server, for example with restic over SSH. Our Docker volumes and PostgreSQL guides cover the restores.

Step 6: Check It From Another Machine

A monitor on the same VPS goes down with it, so run Uptime Kuma on another machine with three monitors:

MonitorTypeTarget
n8nHTTP(s)https://n8n.example.com/healthz/readiness
BaserowHTTP(s)https://crm.example.com/api/_health/
BackupPushThe Push URL in your script

n8n's readiness endpoint returns 200 only "if the DB is connected and migrated", and Baserow's health path answers OK. Tick Certificate Expiry Notification on both to catch a failed renewal, and set the push monitor's heartbeat interval to 90000 seconds (25 hours), so a missed night alerts you. For failed workflows, add an error workflow from our n8n monitoring guide.

Who Maintains It

Stacks like this break months later, when nobody updated n8n, the disk filled or the only person with the passwords left. Name an owner for each job:

How oftenJob
DailyRead the alerts from Uptime Kuma and the n8n error workflow
WeeklyCheck free space with df -h / and that last night's files are in /var/backups/automation
MonthlyRead the release notes, raise the image tags (keep pg17), run sudo docker compose pull and sudo docker compose up -d; reboot if /var/run/reboot-required exists; test a restore
QuarterlyRemove accounts of people who left, in both apps
YearlyCheck which PostgreSQL versions n8n supports, and move off 17 with a dump and restore before it drops off that list

n8n advises updating "at least once a month", and Baserow "will automatically upgrade itself on startup", so update after a good backup; docker compose up -d recreates only changed containers and keeps their volumes. PostgreSQL ships minor releases "at least once every three months" and supports 17 until 8 November 2029, but n8n supports only the two newest majors plus one older one, so its list moves up each year. A newer major can't open its data files, so just raising the tag fails with database files are incompatible with server.

Give the job to one person and a deputy who can open the same password manager, as our disaster recovery checklist suggests. Space-Node VPS plans are self-managed: you run all of this, and backups are not included in the plan price but can be arranged through a support ticket. Want help managing the server? Ask support about the scope before you order, and see our managed vs self-managed guide.

If Something Fails

  • No certificate: sudo docker compose logs caddy shows why. Check the A and AAAA records and that ports 80 and 443 reach the server.
  • n8n or Baserow can't log in to PostgreSQL: the role's password differs from .env. In the psql shell from Step 3, run ALTER ROLE n8n WITH PASSWORD '...'; (or baserow) with the value from .env, then restart that app, for example sudo docker compose restart n8n.

FAQ

Do I need Redis? No. n8n needs Redis only in queue mode, where workers pick up executions from a queue, and the Baserow image runs its own Redis inside the container unless you set REDIS_URL.

Why doesn't Baserow send emails? Without EMAIL_SMTP and the other SMTP variables, Baserow "will not send emails and just log them to the Celery worker logs instead", so invitations and resets never arrive. Add your mail provider's SMTP settings to the baserow service.

Can my business use these tools for free? n8n's Sustainable Use License allows use "for your own internal business purposes". Baserow's Open Source Edition is MIT licensed; its premium and enterprise parts have their own licences. Twenty is mostly AGPLv3.

Should n8n keep SQLite instead? SQLite, n8n's default, works for one instance with a few users, as our SQLite or PostgreSQL guide explains. PostgreSQL runs here anyway for Baserow, so n8n on it costs little and gives both apps one backup routine.

Sources

All opened on 31 October 2026: