On Synology DSM, n8n and the services around it run as ordinary Docker Compose projects, apart from a few things DSM does its own way. Container Manager creates each project folder owned by uid 1000, which has no DSM account, and DSM then refuses to save edits to the compose file inside it. DSM already runs its own Postgres on 127.0.0.1:5432. The kernel has no CPU CFS scheduler, so Compose rejects cpus: on every service, while mem_limit works.
This note sets up the stack I run on a DS720+. n8n runs with external task runners and one Postgres that holds a database and login role per service. SearXNG handles web search, Browserless renders pages and ntfy sends notifications, while NocoDB and Grafana work on the data n8n collects. n8n and its runners form one Container Manager project, every other service has its own, and all of them share one Docker network. Postgres and Browserless publish no port, and everything else binds to the NAS's LAN address.
It is the second note on n8n on DSM. The first, Running the n8n Assistant code sandbox on Synology DSM, covers the Assistant's sandbox, which lives in the same n8n project and is left out here. So are Umami and Uptime Kuma, which run on the same NAS, and anything beyond the LAN, such as remote access and TLS. The compose files, the setup scripts and a verify.sh that runs the checks in this note are in w0rldart/n8n-stack-synology.
Hardware and versions
| Item | Value |
|---|---|
| Hardware | Synology DS720+, Celeron J4125, 9.8 GB RAM |
| DSM | 7.3.2-86009 Update 4 |
| n8n and task runners | 2.38.1 |
| Postgres | 18.6, image tag 18-trixie |
| SearXNG | 2026.9.15-ca4965040 |
| Browserless (Chromium image) | 2.56.7 |
| ntfy | 2.28.0 |
| NocoDB | 2026.09.0 |
| Grafana OSS | 13.0.2 |
| Checked | 28 September 2026 |
Every image except Postgres is pinned to the exact version in the table. With :latest, the compose file does not say which version runs, and any pull and recreate upgrades the service. n8n and its runners share one variable, N8N_VERSION, because n8n requires the runners image to match the n8n image. My first run had n8n on :stable while the runners were pinned to 2.38.1, and one variable rules that mismatch out. Postgres is pinned to its major version and its Debian release, 18-trixie. Minor releases keep the data format, so a newer one replaces the binaries and leaves the data directory as it is. A major release needs a dump and restore, pg_upgrade or replication. The Debian part matters too. A new major release of the operating system can change collation definitions, which PostgreSQL warns can corrupt indexes.
The stack on one shared network
svc: external network, shared by every project
n8n LAN:5679 -> 5678
postgres no published port
searxng LAN:7095 -> 8080
browserless no published port
ntfy LAN:7094 -> 80
nocodb LAN:7090 -> 8080
grafana LAN:7092 -> 3000
runner-net: internal
n8n, n8n-runners
sandbox-net: the Assistant's sandbox, covered in the first note
n8n and the sandbox services
svc is created once, outside every project, so docker compose down in one project cannot remove it:
sudo docker network create svc
My first version kept Postgres inside the n8n project. Compose gives each project its own default network, so services in other projects could not reach it. Each compose file now declares svc with external: true. On svc, containers reach each other by name: n8n connects to postgres:5432, searxng:8080, browserless:3000 and ntfy:80. A service publishes a port only when a person opens it in a browser or a device subscribes to it. Each published port binds to the LAN address in BIND_IP (N8N_BIND_IP for n8n), so none of these services listens on the NAS's other interfaces.
Folders and secrets, created once
One script, run once with sudo, creates the folders, hands the n8n and Grafana data folders to the users those containers run as, and writes every .env. Every new secret is hex, which needs no quoting or escaping in a Compose .env or in a shell that sources one. My old encryption key contained (, *, # and @, which Compose reads correctly and a shell sourcing the file unquoted does not.
#!/bin/bash
# Creates the folders and writes every .env, once. Run with sudo.
set -e
D=/volume1/docker
BIND_IP=192.168.8.159 # the NAS's LAN address
TZ_VAL=Europe/Zurich
if [ -e "$D/postgres/.env" ]; then
echo "$D/postgres/.env exists, nothing written" >&2
exit 1
fi
gen() { openssl rand -hex "$1"; }
mkdir -p "$D"/postgres/{data,init} "$D"/n8n/{data,files} "$D"/searxng/config \
"$D"/browserless "$D"/ntfy/{cache,config} "$D"/nocodb/data "$D"/grafana/data
chown -R 1000:1000 "$D"/n8n/data "$D"/n8n/files # n8n runs as node, uid 1000
chown -R 472:0 "$D"/grafana/data # Grafana runs as uid 472
N8N_PW=$(gen 24)
REG_PW=$(gen 24)
NOCO_PW=$(gen 24)
# Keep an existing n8n key: from .env, or the one n8n wrote to data/config.
[ -f "$D/n8n/.env" ] && cp "$D/n8n/.env" "$D/n8n/.env.bak"
N8N_KEY=$(sed -n 's/^N8N_ENCRYPTION_KEY=//p' "$D/n8n/.env" 2>/dev/null | tr -d "\"'" || true)
[ -n "$N8N_KEY" ] || N8N_KEY=$(sed -n 's/.*"encryptionKey": *"\([^"]*\)".*/\1/p' \
"$D/n8n/data/config" 2>/dev/null || true)
[ -n "$N8N_KEY" ] || N8N_KEY=$(gen 32)
cat > "$D/postgres/.env" <<EOF
POSTGRES_ADMIN_USER=dbadmin
POSTGRES_ADMIN_PASSWORD=$(gen 24)
N8N_DB_PASSWORD=$N8N_PW
REGISTERS_DB_PASSWORD=$REG_PW
NOCODB_DB_PASSWORD=$NOCO_PW
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/n8n/.env" <<EOF
N8N_VERSION=2.38.1
N8N_BIND_IP=$BIND_IP
N8N_HOST=$BIND_IP
POSTGRES_USER=n8n
POSTGRES_PASSWORD=$N8N_PW
POSTGRES_DB=n8n
N8N_ENCRYPTION_KEY='$N8N_KEY'
N8N_RUNNERS_AUTH_TOKEN=$(gen 32)
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/nocodb/.env" <<EOF
BIND_IP=$BIND_IP
HOST_IP=$BIND_IP
POSTGRES_USER=nocodb
POSTGRES_PASSWORD=$NOCO_PW
NC_AUTH_JWT_SECRET=$(gen 32)
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/searxng/.env" <<EOF
BIND_IP=$BIND_IP
SEARXNG_SECRET=$(gen 32)
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/browserless/.env" <<EOF
BROWSERLESS_TOKEN=$(gen 16)
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/ntfy/.env" <<EOF
BIND_IP=$BIND_IP
GENERIC_TIMEZONE=$TZ_VAL
EOF
cat > "$D/grafana/.env" <<EOF
BIND_IP=$BIND_IP
GRAFANA_DB_PASSWORD=$(gen 24)
GRAFANA_ADMIN_PASSWORD=$(gen 24)
GENERIC_TIMEZONE=$TZ_VAL
EOF
chmod 600 "$D"/*/.env
It stops if postgres/.env exists. The per-service passwords reach Postgres through its init script, which the official image runs only when the data directory is empty. A second run would put new passwords in every .env while the database kept the old ones. It keeps an existing n8n encryption key, because n8n encrypts the credentials in its database with it. n8n also refuses to start when the key in data/config differs from the one in its environment. The two chowns follow the images: n8n runs as node and Grafana as uid 472.
The password in REGISTERS_DB_PASSWORD is the one typed by hand later, in n8n's Postgres credential and in NocoDB.
Container Manager and uid 1000
Container Manager's Project panel creates /volume1/docker/<service>/ owned by uid 1000. My DSM user is uid 1026, and no DSM account has uid 1000. DSM refused to save my edits to the compose files with "user account has not been assigned proper privileges". Once the projects exist, the top level of each belongs to your DSM user:
for s in postgres n8n searxng browserless ntfy nocodb grafana; do
sudo chown "$(id -un)":users "/volume1/docker/$s" \
"/volume1/docker/$s/compose.yaml" "/volume1/docker/$s/.env"
done
The chown is not recursive. Data folders stay with the uid each container writes them as, which ls -ln shows. A recursive chown to your own user would stop a container running as another uid from writing to them.
n8n's Compose guide uses named volumes. Everything here is a bind mount under /volume1/docker, the shared folder Container Manager creates, so the data shows up in File Station and in DSM's backup tools like any other shared folder.
Postgres with a database and role per service
One Postgres container holds the database of every service here that needs one, and it publishes no port. DSM's own Postgres listens on 127.0.0.1:5432, so anything on the NAS host that connects to localhost reaches DSM's server. This one is reached as postgres:5432 from svc, or through docker exec. Every psql call names its user: the image creates the superuser from POSTGRES_USER, here dbadmin, and there is no postgres role.
services:
postgres:
image: postgres:18-trixie
container_name: postgres
restart: unless-stopped
environment:
# Admin role: backups and creating databases. No application uses it.
POSTGRES_USER: ${POSTGRES_ADMIN_USER}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_DB: postgres
# Keeps the data where the bind mount is. See below.
PGDATA: /var/lib/postgresql/data
TZ: ${GENERIC_TIMEZONE}
# Per-service passwords, read by the init script.
N8N_DB_PASSWORD: ${N8N_DB_PASSWORD}
REGISTERS_DB_PASSWORD: ${REGISTERS_DB_PASSWORD}
NOCODB_DB_PASSWORD: ${NOCODB_DB_PASSWORD}
volumes:
- type: bind
source: /volume1/docker/postgres/data
target: /var/lib/postgresql/data
- type: bind
source: /volume1/docker/postgres/init
target: /docker-entrypoint-initdb.d
read_only: true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_ADMIN_USER} -d postgres"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks: [svc]
networks:
svc:
external: true
PGDATA is there because of a change in the image. From PostgreSQL 18, the official image keeps its data in /var/lib/postgresql/18/docker. Its entrypoint refuses to start when a mount sits at the old /var/lib/postgresql/data and PGDATA is left at the new default. Setting PGDATA keeps the old layout, and n8n's Compose guide sets the same line. On a new install, the image's own recommendation is a single mount at /var/lib/postgresql.
The init script gives each service its own database, owned by its own login role, with access revoked from PUBLIC:
#!/bin/bash
# A .sh file in /volume1/docker/postgres/init, mode 644.
# Runs once, on first start, only when the data directory is empty.
set -e
create() {
local role="$1" db="$2" pass="$3"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-SQL
CREATE ROLE ${role} LOGIN PASSWORD '${pass}';
CREATE DATABASE ${db} OWNER ${role};
REVOKE ALL ON DATABASE ${db} FROM PUBLIC;
SQL
echo "created role ${role} and database ${db}"
}
create n8n n8n "${N8N_DB_PASSWORD}"
create registers registers "${REGISTERS_DB_PASSWORD}"
create nocodb nocodb "${NOCODB_DB_PASSWORD}"
The file needs mode 644 because the entrypoint switches to the postgres user before it runs the scripts, and a file only root can read makes the first start fail. Since PostgreSQL 15, the public schema of a new database is owned by pg_database_owner, which stands for the database's owner. So each role can create tables in its own database without further grants. The result, from \l:
sudo docker exec postgres psql -U dbadmin -d postgres -c '\l'
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
-----------+-----------+----------+-----------------+------------+------------+--------+-----------+-------------------------
grafana | grafana | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | grafana=CTc/grafana
n8n | n8n | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | n8n=CTc/n8n
nocodb | nocodb | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | nocodb=CTc/nocodb
postgres | dbadmin | UTF8 | libc | en_US.utf8 | en_US.utf8 | | |
registers | registers | UTF8 | icu | en_US.utf8 | en_US.utf8 | und | | registers=CTc/registers+
| | | | | | | | grafana=c/registers
template0 | dbadmin | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | =c/dbadmin +
| | | | | | | | dbadmin=CTc/dbadmin
template1 | dbadmin | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | =c/dbadmin +
| | | | | | | | dbadmin=CTc/dbadmin
umami | umami | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | umami=CTc/umami
(8 rows)
n8n=CTc/n8n with no entry for PUBLIC means no other login role can connect to n8n, so a service holding one credential cannot open another service's database. The admin role can, as a superuser. umami belongs to Umami, outside this note.
registers is the one database several applications use. n8n writes the register data into it and NocoDB edits the same rows, both as the registers role. Grafana reads it as grafana, which is the grafana=c/registers CONNECT in its row. Its ICU collation, kept through the move from Postgres 16 described below, is why its row differs from the others.
One server for every service means one dump holds every service's database, which is why Grafana keeps its data in Postgres over its default SQLite. The cost is shared downtime. An upgrade or an outage stops every service at once. Uptime Kuma keeps its own SQLite, since it is small and self-contained.
The move from 16 to 18 showed that cost. Changing the image tag fails with database files are incompatible with server, so it was a dump and restore into a new data directory, with every client stopped. Two details decide whether the data comes back intact. A database restored into one created with default settings loses its collation, so each database was created with its original locale first, ICU for registers. The init scripts would also fire on the empty data directory and create the databases before the restore, so they were moved aside until it finished. The old data directory was renamed, which kept rollback to a rename and an image tag.
n8n and its external task runners
services:
n8n:
image: docker.n8n.io/n8nio/n8n:${N8N_VERSION}
container_name: n8n
restart: unless-stopped
ports:
- "${N8N_BIND_IP}:5679:5678"
environment:
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_HOST: ${N8N_HOST}
N8N_PORT: 5678
N8N_PROTOCOL: http
N8N_EDITOR_BASE_URL: "http://${N8N_HOST}:5679/"
WEBHOOK_URL: "http://${N8N_HOST}:5679/"
N8N_SECURE_COOKIE: "false"
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
# data/config, which holds a copy of the key, gets mode 600.
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_RESTRICT_FILE_ACCESS_TO: /files
N8N_BLOCK_ENV_ACCESS_IN_NODE: "true"
N8N_RUNNERS_MODE: external
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
N8N_RUNNERS_TASK_TIMEOUT: "300"
# The Assistant's web search. Its other settings are in the first note.
N8N_INSTANCE_AI_SEARXNG_URL: http://searxng:8080
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
EXECUTIONS_DATA_PRUNE_MAX_COUNT: "5000"
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_PERSONALIZATION_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "true"
# Node's heap ceiling for n8n, in MB.
NODE_OPTIONS: "--max-old-space-size=3072"
GENERIC_TIMEZONE: ${GENERIC_TIMEZONE}
TZ: ${GENERIC_TIMEZONE}
volumes:
- type: bind
source: /volume1/docker/n8n/data
target: /home/node/.n8n
- type: bind
source: /volume1/docker/n8n/files
target: /files
# The first note adds sandbox-net and the sandbox services.
networks: [svc, runner-net]
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5678/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
runners:
image: ghcr.io/n8n-io/runners:${N8N_VERSION}
container_name: n8n-runners
restart: unless-stopped
depends_on: [n8n]
environment:
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: "15"
TZ: ${GENERIC_TIMEZONE}
networks: [runner-net]
read_only: true
tmpfs:
- /tmp:size=256m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
mem_limit: 1g
networks:
svc:
external: true
runner-net:
driver: bridge
internal: true
N8N_BLOCK_ENV_ACCESS_IN_NODE stops expressions and Code nodes from reading environment variables, so neither can read the encryption key or the database password. Every secret a workflow needs then has to be an n8n credential, and here that includes the Browserless token and a PageSpeed API key. N8N_RESTRICT_FILE_ACCESS_TO limits n8n's file access to /files, a folder bind-mounted from the NAS. N8N_SECURE_COOKIE is off because the editor is plain HTTP on the LAN, and the default setting sends the session cookie over HTTPS only.
n8n reads all of this at start, so a change needs the container recreated with sudo docker compose up -d --force-recreate n8n, run in the project folder. Without the service name, it recreates every container in the project, the sandbox included. A restart, from the shell or from Container Manager, keeps the environment the container was created with.
Code nodes run in the n8n-runners container. In n8n's default internal mode, the runner is a child process of n8n running as the same user, so code that escapes it has n8n's access, stored credentials included. External mode moves it to its own container on runner-net, an internal network. Docker configures no default route there and drops traffic to other networks, so a Code node has no path to the LAN or the internet. The exception Docker documents is the network's gateway address, which belongs to the NAS, so a DSM service listening on all interfaces can be reached from a Code node. The runner container is read-only with every capability dropped, /tmp on a 256 MB tmpfs and a 1 GB memory limit.
The broker listens on 127.0.0.1 by default, which a separate container cannot reach, so N8N_RUNNERS_BROKER_LISTEN_ADDRESS is 0.0.0.0. That also makes the broker reachable from svc and sandbox-net, and the auth token is what stops another container there from registering as a runner. Port 5679 appears twice with two meanings: the broker's default port inside the n8n container, and the editor's port on the NAS, mapped to n8n's own 5678.
Both runner timeouts are written out at their current defaults, so an upgrade that changes a default does not change them. Before the task timeout was set, n8n logged this at startup, along with a request to drop N8N_RUNNERS_ENABLED, which my first compose file still set:
- N8N_RUNNERS_ENABLED -> Remove this environment variable; it is no longer needed.
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced from 300 (5 minutes) to 60 (1 minute) in a future version. Set it explicitly to keep your current task timeout.
Some of my workflows run probe and audit loops longer than a minute, so the explicit 300 stays. N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT shuts an idle runner down after 15 seconds, and the launcher starts a new one for the next task. Both launchers registered with n8n on the first start, on 20 September:
sudo docker logs n8n --tail 30 | grep "Registered runner"
Registered runner "launcher-javascript" (ca21cfce0588f7ef)
Registered runner "launcher-python" (b690754494e25f82)
Execution data is pruned at n8n's default age of 336 hours, which is 14 days, and the count is capped at 5,000 executions against a default of 10,000.
The last lines of the runner's log include two ERROR lines:
2026/09/28 20:00:49 ERROR [runner:js] (node:467) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.
2026/09/28 20:00:49 ERROR [runner:js] (Use `node --trace-warnings ...` to show where the warning was created)
Both are one Node.js warning. The launcher then carries on with its normal idle cycle:
2026/09/28 20:01:15 INFO [launcher:js] Runner process exited on idle timeout
2026/09/28 20:01:15 INFO [launcher:js] Waiting for task broker to be ready...
2026/09/28 20:01:15 INFO [launcher:js] Waiting for launcher's task offer to be accepted...
SearXNG, Browserless and ntfy
services:
searxng:
image: searxng/searxng:2026.9.15-ca4965040
container_name: searxng
restart: unless-stopped
ports:
# For testing queries in a browser. n8n uses http://searxng:8080.
- "${BIND_IP}:7095:8080"
environment:
SEARXNG_BASE_URL: "http://${BIND_IP}:7095/"
SEARXNG_SECRET: ${SEARXNG_SECRET}
TZ: ${GENERIC_TIMEZONE}
volumes:
- type: bind
source: /volume1/docker/searxng/config
target: /etc/searxng
cap_drop: [ALL]
cap_add: [CHOWN, SETGID, SETUID]
networks: [svc]
networks:
svc:
external: true
n8n's SearXNG node needs JSON results, which SearXNG's default configuration does not serve. On first start, the container writes settings.yml into the mounted config folder from its template. Add json to its search.formats list and restart SearXNG:
search:
formats:
- html
- json
The same instance serves the Assistant's web search through N8N_INSTANCE_AI_SEARXNG_URL. SearXNG queries public search engines without API keys, and the engines push back. The check under Verification shows DuckDuckGo answering with a CAPTCHA. During a run of company domain lookups from one of my workflows, DuckDuckGo returned CAPTCHAs, while Google CSE and Brave refused the queries as too many requests. SearXNG still answered with an empty results list, and a workflow that reads only results cannot tell that apart from a search that found nothing. n8n's Assistant setup guide recommends Brave Search's API for a reliable setup, and it takes priority over SearXNG when both are set.
services:
browserless:
image: ghcr.io/browserless/chromium:v2.56.7
container_name: browserless
restart: unless-stopped
environment:
TOKEN: ${BROWSERLESS_TOKEN}
CONCURRENT: "1"
QUEUED: "10"
TIMEOUT: "60000"
TZ: ${GENERIC_TIMEZONE}
shm_size: "1gb"
networks: [svc]
networks:
svc:
external: true
Browserless publishes no port. n8n is its only client and reaches it as http://browserless:3000. A published port would put a headless browser on the LAN with only its token in the way. Against Browserless's defaults, CONCURRENT drops from 10 sessions to one, because CPU is what runs out first on this NAS. QUEUED stays at its default of 10 waiting sessions, and TIMEOUT doubles the default session timeout to 60 seconds. Chromium keeps its shared memory in /dev/shm, and shm_size raises that from Docker's default of 64 MiB to 1 GB.
services:
ntfy:
image: binwiederhier/ntfy:v2.28.0
container_name: ntfy
restart: unless-stopped
command: serve
ports:
- "${BIND_IP}:7094:80"
environment:
NTFY_BASE_URL: "http://${BIND_IP}:7094"
NTFY_LISTEN_HTTP: ":80"
NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
NTFY_CACHE_DURATION: "72h"
NTFY_BEHIND_PROXY: "false"
TZ: ${GENERIC_TIMEZONE}
volumes:
- type: bind
source: /volume1/docker/ntfy/cache
target: /var/cache/ntfy
- type: bind
source: /volume1/docker/ntfy/config
target: /etc/ntfy
networks: [svc]
networks:
svc:
external: true
Workflows publish with a POST to http://ntfy/<topic>, and clients subscribe on the LAN port. This instance has no authentication and stays on the LAN. ntfy's default access is read-write, so anyone who can reach an open instance can publish to any topic or read it. Before this one is reachable from anywhere else, it needs NTFY_AUTH_FILE and NTFY_AUTH_DEFAULT_ACCESS: deny-all, the setting ntfy's docs give for a private instance.
NocoDB and Grafana on the register data
NocoDB and Grafana are separate projects:
services:
nocodb:
image: nocodb/nocodb:2026.09.0
container_name: nocodb
restart: unless-stopped
ports:
- "${BIND_IP}:7090:8080"
environment:
# NocoDB's own metadata. The register data is attached later.
NC_DB: "pg://postgres:5432?u=nocodb&p=${POSTGRES_PASSWORD}&d=nocodb"
NC_AUTH_JWT_SECRET: ${NC_AUTH_JWT_SECRET}
NC_PUBLIC_URL: "http://${HOST_IP}:7090"
NC_DISABLE_TELE: "true"
TZ: ${GENERIC_TIMEZONE}
volumes:
- type: bind
source: /volume1/docker/nocodb/data
target: /usr/app/data
networks: [svc]
networks:
svc:
external: true
services:
grafana:
# Same images as grafana/grafana-oss, which Grafana's docs retire.
image: grafana/grafana:13.0.2
container_name: grafana
restart: unless-stopped
ports:
- "${BIND_IP}:7092:3000"
environment:
GF_DATABASE_TYPE: postgres
GF_DATABASE_HOST: postgres:5432
GF_DATABASE_NAME: grafana
GF_DATABASE_USER: grafana
GF_DATABASE_PASSWORD: ${GRAFANA_DB_PASSWORD}
GF_DATABASE_SSL_MODE: disable
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_SERVER_ROOT_URL: "http://${BIND_IP}:7092"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
TZ: ${GENERIC_TIMEZONE}
volumes:
- type: bind
source: /volume1/docker/grafana/data
target: /var/lib/grafana
networks: [svc]
networks:
svc:
external: true
NocoDB keeps its own metadata in the nocodb database. The register data is attached afterwards in NocoDB as an external Postgres source, with the registers credential, so NocoDB edits the rows n8n writes without copying them.
Grafana keeps its dashboards, users and settings in the grafana database, next to everything else. On registers, the grafana role holds these grants:
sudo docker exec postgres psql -U dbadmin -d registers -c "SELECT grantee, privilege_type, count(*) FROM information_schema.role_table_grants WHERE grantee = 'grafana' GROUP BY 1, 2;"
grantee | privilege_type | count
---------+----------------+-------
grafana | SELECT | 16
(1 row)
The role holds 16 SELECT grants and no other table privilege, so a Grafana data source that logs in as grafana can read those tables and cannot change them. The init script covers the services that exist on the first start. A service added later gets the same setup by hand, as the admin role. For Grafana, with the password from grafana/.env:
CREATE ROLE grafana LOGIN PASSWORD '<GRAFANA_DB_PASSWORD>';
CREATE DATABASE grafana OWNER grafana;
REVOKE ALL ON DATABASE grafana FROM PUBLIC;
GRANT CONNECT ON DATABASE registers TO grafana;
-- connected to registers:
GRANT USAGE ON SCHEMA public TO grafana;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana;
ALTER DEFAULT PRIVILEGES FOR ROLE registers IN SCHEMA public
GRANT SELECT ON TABLES TO grafana;
GRANT SELECT ON ALL TABLES covers the tables that exist when it runs. The last statement sets default privileges for tables the registers role creates later. Without it, each new table stays unreadable to Grafana until someone grants it. On this instance it is in place:
sudo docker exec postgres psql -U dbadmin -d registers -c '\ddp'
Default access privileges
Owner | Schema | Type | Access privileges
-----------+--------+-------+---------------------
registers | public | table | grafana=r/registers
(1 row)
Backing up Postgres and the n8n key
[OWNER: how Postgres is backed up today: the command, the schedule, where the dumps go, and when a restore was last tested.]
A dump restores the n8n database without the key that decrypts its credentials. The key is in n8n/.env, and n8n also keeps a copy in data/config. One more copy belongs off the NAS.
Verification
Each check runs inside the n8n container, over the same network path a workflow uses. Output from 28 September 2026:
sudo docker exec n8n sh -c 'wget -qO- "http://searxng:8080/search?q=n8n&format=json" | head -c 120'; echo
{"query": "n8n", "results": [], "answers": [], "corrections": [], "infoboxes": [], "suggestions": [], "unresponsive_engi
The results list is empty. Fetched again, the end of the response names an engine that refused:
sudo docker exec n8n sh -c 'wget -qO- "http://searxng:8080/search?q=n8n&format=json"' | tail -c 400
"template": "default.html", "parsed_url": null, "title": "", "thumbnail": "", "priority": "", "engines": ["wikidata"], "positions": "", "score": 0, "category": "", "publishedDate": null, "iframe_src": null}], "suggestions": ["n8n open source", "n8n community edition", "n8n ai", "n8n github", "n8n cloud", "n8n login", "n8n download", "n8n free"], "unresponsive_engines": [["duckduckgo", "CAPTCHA"]]}
T=$(sudo sed -n 's/^BROWSERLESS_TOKEN=//p' /volume1/docker/browserless/.env | tr -d '"')
sudo docker exec n8n wget -qO /dev/null "http://browserless:3000/json/version?token=$T" && echo "browserless: ok"
browserless: ok
sudo docker exec n8n wget -qO- --post-data "stack check from n8n" http://ntfy/stack-check; echo
{"id":"9dg459IpWfHT","time":1790620280,"expires":1790879480,"event":"message","topic":"stack-check","message":"stack check from n8n"}
The SearXNG checks show n8n reaching SearXNG and getting JSON back. The Browserless check reaches its /json/version endpoint with the token, and the ntfy check publishes a real message to the stack-check topic. After any image update, run them again. verify.sh in the repo runs them together with the Postgres, port and runner checks.
What to check Monday
- Run
sudo grep -n 'image:' /volume1/docker/*/compose.yamland pin every image that shows:latestor no tag at all. - Run
sudo docker ps --format '{{.Names}} {{.Ports}}'and confirm that every published port shows the LAN address, and thatpostgresandbrowserlessshow only a container port, with no address in front of it. - Make every workflow that searches through SearXNG check
unresponsive_enginesbefore it treats an emptyresultslist as a miss. - Run
sudo find /volume1/docker -maxdepth 2 \( -name compose.yaml -o -name .env \) -exec ls -ln {} +and confirm your DSM uid owns each file and every.envis mode 600. - Search your workflows for
$envbefore turning onN8N_BLOCK_ENV_ACCESS_IN_NODE. Each expression that reads one fails afterwards, and the secret it reads belongs in a credential.
Related work
The Assistant's sandbox runs inside this n8n project, on sandbox-net, and its DSM workarounds are in Running the n8n Assistant code sandbox on Synology DSM. If you are building something similar on your own hardware, get in touch.