What NocoDB needs from the machine
The self-hosting overview gives two rows of figures and recommends a Linux server for production. They cover the whole stack, database included:
| Resource | Minimum | Recommended (production) |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU |
| RAM | 2 GB | 8 GB |
| Disk | 10 GB | 50 GB+ |
Disk is the figure that moves. Attachments land in the nocodb_data volume (mounted at /usr/app/data) unless you configure S3-compatible storage with the NC_S3_* variables, and each attachment is capped at 20 MiB by default (NC_ATTACHMENT_FIELD_SIZE). Rows, metadata, users and comments live in PostgreSQL. Without NC_DB NocoDB falls back to a local SQLite file; the migration guide recommends PostgreSQL for every deployment, and a paid licence will not activate on SQLite or MySQL.
The licence, AGPL until 0.265 and Sustainable Use License after
Our general reading, not legal advice. Up to release 0.265.1 (October 2025) the repository's LICENSE file was AGPL-3.0: anyone distributing copies, modified or not, has to pass on the source, and a modified build served to users over a network must offer them its source. A commit on 8 January 2026 replaced that file with LICENSE.md, the Sustainable Use License 1.0, first shipped in 0.301.0 on 13 January 2026; the licence page dates the switch to 9 January. Copies of the older releases keep the licence they were published under.
The Limitations clause of LICENSE.md lets you use or modify NocoDB only for your own internal business purposes or for non-commercial or personal use, and lets you distribute it or provide it to others only free of charge for non-commercial purposes. Its Notices clause adds that anyone receiving a copy from you gets these terms, and a modified copy carries a prominent notice saying so. The licence page lists the cases that need a commercial licence: offering NocoDB as a hosted or managed service, giving outside customers direct access to it, reselling it in a paid offering, or embedding it where customers interact with NocoDB itself. It also says the SUL is not OSI-approved and calls NocoDB source-available.
The machine to run it on
A dedicated server gives PostgreSQL the disks to itself: one tenant, no hypervisor, root, and an unmetered port. NocoDB publishes amd64 and arm64 images, but the setup below is written for Ubuntu 24.04 (whose archive carries Caddy) and Docker Engine 28, which rules out the X-Gene ARM cartridge (it boots Ubuntu 16.04 and 18.04 only). Classes from the range that clear the recommended row:
- Xeon E3-1585L v5 cartridge (HPE m710x): 4 cores at 3 GHz, 16 to 64 GB, NVMe. One NocoDB instance per box, with its own console.
- Single-socket rack servers, the usual base for an application with its database on the same machine.
- Dual-socket rack servers (Xeon E5-2630L v3, E5-2650L v2, Gold 6130), with room for NocoDB beside other internal tools, or a large attachment store.
Every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC). Of the cartridges, the m510 and m710x have one too; the rest get power control only. Hand-over of a ready server takes about 30 minutes from payment approval. A build to order takes 4 to 24 hours. For keeping the database apart from the app, see PostgreSQL on a dedicated server.
Installing the Compose stack
The docs' noco.sh installer puts a Traefik stack with a Let's Encrypt certificate online before anyone has signed up, and the first sign-up becomes super admin, so we build from the plain quickstart-demo example, pinned to tag 2026.10.1. Our changes, in full: the demo password quickstart_demo_pw_change_me becomes ${PG_PW}, NC_SITE_URL becomes ${SITE_URL}, the nocodb/nocodb:latest image becomes ${NC_TAG}, port 8080 binds to 127.0.0.1 only, and an NC_AUTH_JWT_SECRET entry set to ${NC_JWT} goes under both the nocodb and worker services (a grep checks for exactly two). Compose reads those four values from /opt/nocodb/.env, created at mode 600.
The environment-variables page marks NC_AUTH_JWT_SECRET mandatory, as the secret that makes authentication tokens, and says NocoDB generates a random one when it is unset. We set it so it lives in .env and in every backup. Never change it afterwards: in our inference, a new value invalidates every sign-in token already issued.
Install Docker Engine and the Compose plugin from Docker's apt repository, sudo -i, then paste. The block stops below Engine 28, because a 127.0.0.1 mapping keeps other hosts out only from 28 on (moby#45610), and it refuses to run when .env already exists: a second run would hand Compose a new database password that the initialised Postgres volume never learns. It turns on ufw with SSH only, IPv6 rules included.
V=2026.10.1; D=/opt/nocodb; F=$D/docker-compose.yml
G=https://raw.githubusercontent.com/nocodb/nocodb/$V/docker-compose/examples/quickstart-demo/docker-compose.yml
E=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if ! [ "${E%%.*}" -ge 28 ] 2>/dev/null; then echo "STOP - Docker Engine 28+ needed, have '$E'"
elif [ -e $D/.env ]; then echo "STOP - $D/.env exists, left alone"
else
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable &&
mkdir -p $D && curl -fsSL "$G" -o $F &&
sed -i -e "s/quickstart_demo_pw_change_me/\${PG_PW}/g" \
-e "s|'http://localhost:8080'|\"\${SITE_URL}\"|g" \
-e "s|'8080:8080'|'127.0.0.1:8080:8080'|" \
-e "s|nocodb/nocodb:latest|nocodb/nocodb:\${NC_TAG}|g" $F &&
sed -i '/NC_REDIS_URL:/a\ NC_AUTH_JWT_SECRET: "${NC_JWT}"' $F &&
[ "$(grep -c 'NC_AUTH_JWT_SECRET: "${NC_JWT}"' $F)" = 2 ] &&
grep -q "'127.0.0.1:8080:8080'" $F && ! grep -q 'quickstart_demo\|:latest' $F &&
P=$(openssl rand -hex 24) && [ -n "$P" ] && J=$(openssl rand -hex 32) && [ -n "$J" ] &&
install -m 600 /dev/null $D/.env &&
printf 'PG_PW=%s\nNC_JWT=%s\nSITE_URL=http://localhost:8080\nNC_TAG=%s\n' "$P" "$J" "$V" > $D/.env &&
docker compose -f $F up -d && echo "started"
fi
curl -fsS http://127.0.0.1:8080/api/v1/health answers once the app is up, and monitoring can poll the same path. printf is a bash builtin, so neither secret appears as a process argument, though root and the docker group can read both with docker inspect. Redis, password-less here, has no published port. No .env written? Fix the cause and paste again. If .env exists and only docker compose up failed, keep it (Postgres may have initialised with its password) and rerun docker compose -f /opt/nocodb/docker-compose.yml up -d alone.
First sign-up over a tunnel, then HTTPS
On your laptop, ssh -L 8080:127.0.0.1:8080 -N you@SERVER_IP forwards the port; point a browser at http://localhost:8080 and sign up. That account is the super admin. If the instance is only for invited colleagues, switch off public sign-up in the super admin settings (the same switch as NC_INVITE_ONLY_SIGNUP).
Then publish an A record for the domain (plus AAAA if you added IPv6 at checkout) and run the block below as root. It installs Caddy from the Ubuntu 24.04 archive, which fetches its own certificate, opens 80 and 443, and switches NC_SITE_URL to the https address, which invitation and password-reset mails use for their links. Caddy passes the original Host through and sets X-Forwarded-Proto, the two headers NocoDB's proxy instructions ask for.
DOMAIN=nocodb.example.com
D=/opt/nocodb; F=$D/docker-compose.yml
if [ "$DOMAIN" = nocodb.example.com ]; then echo "STOP - put your own name in DOMAIN"
elif ! getent hosts "$DOMAIN" > /dev/null; then echo "STOP - $DOMAIN does not resolve yet"
else
apt-get update && apt-get install -y caddy &&
printf '%s {\n\treverse_proxy 127.0.0.1:8080\n}\n' "$DOMAIN" > /etc/caddy/Caddyfile &&
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile &&
ufw allow 80,443/tcp && systemctl reload caddy &&
sed -i "s|^SITE_URL=.*|SITE_URL=https://$DOMAIN|" $D/.env &&
grep -qx "SITE_URL=https://$DOMAIN" $D/.env &&
docker compose -f $F up -d --force-recreate nocodb worker && echo "public"
fi
Compose does not reapply a changed .env to running containers on restart; up -d --force-recreate rebuilds them with the new value. Caddy runs on the host, so ufw filters it on IPv4 and IPv6 alike, hence the 80/443 rule. Had the stack published 8080 without an address, Docker would listen on 0.0.0.0 and [::] and skip ufw on IPv4, and on IPv6 too once Docker's own IPv6 networking is on.
Backups and a restore you can undo
The backups page names three things: the database, the attachments volume and the config files, and says a volume on the same host is not a backup. The block writes two root-only scripts. backup.sh dumps PostgreSQL under a temporary name, archives nocodb_data read-only through an alpine container and packs .env with the compose file, then tests the dump (pg_restore -l) and both archives and prints the stamp. The docs use a plain SQL dump; the custom format (-Fc) and pg_restore are our change. The volume is nocodb_nocodb_data because the project directory is /opt/nocodb.
install -d -m 700 /var/backups/nocodb && cd /opt/nocodb &&
cat > backup.sh <<'EOF' &&
set -euo pipefail; umask 077
B=/var/backups/nocodb; T=$(date +%F-%H%M%S); C="docker compose -f /opt/nocodb/docker-compose.yml"
$C exec -T db pg_dump -U nocodb -Fc nocodb > "$B/db-$T.part"
mv "$B/db-$T.part" "$B/db-$T.dump"
$C exec -T db pg_restore -l < "$B/db-$T.dump" > /dev/null
docker run --rm -v nocodb_nocodb_data:/data:ro -v "$B":/b alpine tar -czf "/b/data-$T.tar.gz" -C /data .
tar -C /opt/nocodb -czf "$B/config-$T.tar.gz" .env docker-compose.yml
tar -tzf "$B/data-$T.tar.gz" > /dev/null; tar -tzf "$B/config-$T.tar.gz" > /dev/null
echo "saved $T"
EOF
cat > restore.sh <<'EOF' &&
set -euo pipefail; T=${1:?usage: restore.sh STAMP}
B=/var/backups/nocodb; C="docker compose -f /opt/nocodb/docker-compose.yml"
for f in db-$T.dump data-$T.tar.gz config-$T.tar.gz; do [ -s "$B/$f" ]; done
[ -e /opt/nocodb/.env ] && bash /opt/nocodb/backup.sh
tar -xzf "$B/config-$T.tar.gz" -C /opt/nocodb
$C up --no-start; $C up -d db; until $C exec -T db pg_isready -U nocodb; do sleep 2; done
$C stop nocodb worker
$C exec -T db dropdb -U nocodb --if-exists nocodb
$C exec -T db createdb -U nocodb -T template0 nocodb
$C exec -T db pg_restore -U nocodb -d nocodb < "$B/db-$T.dump"
docker run --rm -v nocodb_nocodb_data:/data -v "$B":/b:ro alpine sh -c \
"find /data -mindepth 1 -delete && tar -xzf /b/data-$T.tar.gz -C /data"
$C up -d
EOF
chmod 700 backup.sh restore.sh && bash backup.sh
For a nightly run, put 15 3 * * * root bash /opt/nocodb/backup.sh > /var/log/nocodb-backup.log 2>&1 && /usr/bin/find /var/backups/nocodb -type f -mtime +14 -delete in /etc/cron.d/nocodb-backup, so pruning happens only after a good backup. Pull /var/backups/nocodb from another machine with plain rsync -a, never --delete, and prune there with its own job.
To roll back, as root: bash /opt/nocodb/restore.sh STAMP. It checks the three files exist, backs up the current database, files and config (aborting if that fails), then puts the old config back, recreates the database from the dump and refills the volume. To undo it, rerun with the stamp the safety backup printed; nothing blocks a second pass. That backup needs the database up, so on a stopped stack run docker compose -f /opt/nocodb/docker-compose.yml up -d db first.
- On a new server, as root, install Docker Engine 28+ with Compose and run the install block's three ufw lines.
- mkdir -p /opt/nocodb and install -d -m 700 /var/backups/nocodb (restore.sh reads from there).
- Copy both scripts to /opt/nocodb and one stamp's three files to /var/backups/nocodb.
- bash /opt/nocodb/restore.sh STAMP; with no .env it skips the safety backup.
- Repoint DNS and run the HTTPS block again.
Upgrades and day-two settings
The upgrade docs say pull the new image and restart, back up first, read the changelog, and pin a tag in production. So run bash /opt/nocodb/backup.sh, change NC_TAG in /opt/nocodb/.env and run docker compose -f /opt/nocodb/docker-compose.yml pull && docker compose -f /opt/nocodb/docker-compose.yml up -d. Expect a short outage while the containers restart.
- Cap the json-file logs in /etc/docker/daemon.json (the hardening checklist uses max-size 10m, max-file 5).
- For email, NC_SMTP_HOST, NC_SMTP_PORT, NC_SMTP_USERNAME, NC_SMTP_PASSWORD and NC_SMTP_FROM go in the environment section of the compose file; we add them to both nocodb and worker.
- For licence activation the instance calls app.nocodb.com/api/v1/on-premise/agent on 443 every 6 hours; allow it if you filter outbound traffic.
Where Community Edition stops
The FAQ puts no limit on projects, records or fields. The edition comparison lists what the free build lacks: SSO, audit logs, SCIM, table and field permissions, teams, and more than one workspace. Per the purchase page, Business adds SSO, table- and field-level permissions and teams; Scale adds audit logs, more workspaces and row-level security; Enterprise adds SCIM and an airgapped install. Business and Scale are bought in a NocoDB Cloud account, Enterprise is arranged through NocoDB's sales team, and every licence activates only on PostgreSQL. For automations on your tables, n8n on its own server fits beside it; for a headless content API over Postgres, compare Directus.
Questions
Is self-hosted NocoDB free to run?
Community Edition costs nothing, and the FAQ says it has no cap on projects, records or fields. The Sustainable Use License limits how you may use it: internal business use, or personal and non-commercial use. SSO, audit logs and fine-grained permissions need a paid self-hosted licence.
Can I host NocoDB for my clients?
Not under the free licence, in our reading. The licence page names offering NocoDB as a hosted or managed service, and giving external customers direct access to it, as cases that need a commercial licence. Using it inside your own company, at any size, does not.
How do I stop strangers signing up?
Turn off public sign-up in the super admin settings once your own account exists, or set NC_INVITE_ONLY_SIGNUP. New users then join only by invitation. Do this before the domain goes public, since the first account created becomes super admin.