Can I self-host ToolJet?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called ToolJet. It takes one prompt, a 4096 MB VPS, and about 120 minutes. That is $237 a month you stop paying ToolJet — $2,844 a year on the Pro plan, 3 seats assumed.

Why people pay for ToolJet

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

ToolJet Cloud sells the weeks between a spreadsheet and a real admin panel, and then the part that keeps it alive: a drag-and-drop canvas over your own databases and APIs, a hosted place to keep the tables you have nowhere else to put, and somebody else patching the thing your operations team clicks buttons on every Tuesday. The meter is per builder, which is honest about what they are selling, because the people who only open the apps cost you nothing until you cross an end-user cap. What the higher tiers add is the paperwork an employer eventually asks for: single sign-on, custom user groups, audit logs and your own branding on the login page.

ToolJet plans and list prices
PlanList priceWhat it buys
FreefreeUp to 2 builders, 50 end users, 2 apps and 100 AI credits a month, with community Slack support.
Prothe plan this page prices against$79/mo per seatPer builder per month, billed monthly; the page advertises 20% off on annual billing. Adds up to 50 end users, 5 apps, 2,000 AI credits per builder, custom styling, version control and email support.
Team$199/mo per seatPer builder per month, billed monthly; 20% off annually. Adds up to 100 end users, unlimited apps, SSO, custom user groups, white labelling, a custom domain, audit logs, Git sync and priority support. Sold at the same figure for self-hosted deployments.
Enterprise$3,000/moThe page states a starting price of $3,000 a month rather than a per-seat rate. Adds unlimited end users, SCIM provisioning, custom AI models, custom data retention, premium SLAs and a dedicated support manager, with air-gapped deployment available.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium

Replaced by ToolJet

One project, named before the prompt, so you know what you are about to install.

The internal-tools builder ToolJet sells, installed from ToolJet's own repository, with no per-builder meter and no end-user cap.

The same company writes it, from the same repository, and points its own documentation at the compose file, which makes this one of the rare cases where self-hosting is the vendor's own path rather than a workaround. You get the builder, the query editor, the ToolJet Database and every connector in the marketplace, uncapped on builders, end users and apps, which is the whole per-builder meter gone. What you take on is three containers, one PostgreSQL holding three databases, an image published for amd64 only, and a licence key you never buy: SSO, custom user groups, audit logs, white labelling, Git sync and multi-environment deployment live behind that key in two private submodules and are not in what you install.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. ToolJetour pickONE EVENING— setup effort 2 of 4

    The internal-tools builder ToolJet sells, installed from ToolJet's own repository, with no per-builder meter and no end-user cap.

    The same company writes it, from the same repository, and points its own documentation at the compose file, which makes this one of the rare cases where self-hosting is the vendor's own path rather than a workaround. You get the builder, the query editor, the ToolJet Database and every connector in the marketplace, uncapped on builders, end users and apps, which is the whole per-builder meter gone. What you take on is three containers, one PostgreSQL holding three databases, an image published for amd64 only, and a licence key you never buy: SSO, custom user groups, audit logs, white labelling, Git sync and multi-environment deployment live behind that key in two private submodules and are not in what you install.

  2. AppsmithONE EVENING— setup effort 2 of 4

    Drag-and-drop internal tools over your own databases and APIs, with no per-builder seat and no second meter for the people who only use them.

    The other end of the same trade, and the one to pick if you are choosing an internal-tools builder cold rather than leaving a ToolJet bill. Its community edition is Apache-2.0 end to end instead of open source with a paid half kept out of the repository, and the community around it is larger, so the answer to your third-day question is more likely to already be written down. The install is one container instead of three, at the cost of an image that runs MongoDB, Redis and PostgreSQL inside itself and wants 8 GB of RAM. ToolJet ranks first on this page because this page is ToolJet's own cloud, and swapping a product for itself is a shorter journey than swapping it for a rival.

The swap

You're paying

ToolJet

$237/mo · $2,844/yr

is replaced by

You'd run

ToolJet

ONE EVENING · ~120 min to running · 4096 MB RAM

ToolJet Pro · 3 seats assumed · vendor list price · checked 2026-08-07 · source · confidence: medium

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
20 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same ToolJet: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

332 lines · 14,494 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install ToolJet v3.20.208-lts on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and it becomes `TOOLJET_HOST`, the value the
server compares request origins against.

ToolJet needs 4096 MB of RAM available and 20 GB free on /srv. Upstream sizes the application
machine at 4 GB and the database machine at 8 GB, and this puts both on one box, so 4096 MB is
where it starts rather than where it is comfortable. The image is published for amd64 only.
Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. If the architecture is anything but `amd64`, print it and stop: there is no
arm64 image and an arm64 VPS has no emulation to fall back on. If `dig +short` prints nothing,
print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/tooljet /srv/tooljet/backups
sudo install -d -m 700 /srv/tooljet/postgres
ls -la /srv/tooljet
```

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root. Leave that one alone: the PostgreSQL image chowns its own data directory on first start and
refuses one already chowned. Nothing here is ToolJet's own: its apps, queries and datasource
credentials are rows in that database.

## 3. Secrets

Four secrets: the lockbox master key, the application secret key, the PostgreSQL password and the
PostgREST JWT secret. Generate all four on the server. Do not print any of them, do not repeat
them in your summary, and do not put them in any log line. Hex throughout, at the lengths
upstream documents for each.

```bash
umask 077
cat > /srv/tooljet/.env <<EOF
TOOLJET_HOST=https://<DOMAIN>
LOCKBOX_MASTER_KEY=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 64)
PG_PASS=$(openssl rand -hex 32)
PGRST_JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/tooljet/.env
umask 022
ls -l /srv/tooljet/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user `LOCKBOX_MASTER_KEY` is the one to
copy into their password manager tonight, readable with
`sudo grep LOCKBOX_MASTER_KEY /srv/tooljet/.env`. It encrypts every database password, API key and
token they later hand to a datasource, so a database restored without it comes back with every app
intact and nothing able to connect.

## 4. compose.yml

```bash
cat > /srv/tooljet/compose.yml <<'EOF'
# ToolJet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://docs.tooljet.ai/docs/setup/docker/
#   variable reference . https://docs.tooljet.ai/docs/setup/env-vars/
#   sizing ............. https://docs.tooljet.ai/docs/setup/system-requirements/
#   tooljet database ... https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# Upstream's in-built-PostgreSQL deployment in our layout: the ToolJet server,
# one PostgreSQL holding the three databases it makes for itself, and the
# PostgREST the ToolJet Database is read through. No Redis service: the -ce
# image starts one in its own container. -ce is the community edition, the tree
# AGPL-3.0 covers; the paid half sits in two private git submodules. Digests
# read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: tooljet-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: tooljet_production
      POSTGRES_USER: tooljet
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - /srv/tooljet/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tooljet -d tooljet_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  tooljet:
    image: tooljet/tooljet-ce:v3.20.208-lts@sha256:78cb01a47c2a0f5efde54ebf2ff3d4c704c1523e1a6b497df65697028701f3c9
    container_name: tooljet
    restart: unless-stopped
    # amd64 only, named rather than guessed. PORT is 3000, not 80, because
    # this image runs as a non-root user.
    platform: linux/amd64
    env_file: /srv/tooljet/.env
    command: ["npm", "run", "start:prod"]
    environment:
      SERVE_CLIENT: "true"
      PORT: "3000"
      # Three databases are made on the first boot.
      PG_HOST: postgres
      PG_USER: tooljet
      PG_DB: tooljet_production
      TOOLJET_DB_HOST: postgres
      TOOLJET_DB_USER: tooljet
      TOOLJET_DB_PASS: ${PG_PASS}
      TOOLJET_DB: tooljet_db
      PGRST_HOST: http://postgrest:3000
      # No browser makes an account except the first administrator's, and
      # neither of the next two phones home, which upstream ships them doing.
      DISABLE_SIGNUPS: "true"
      DISABLE_TOOLJET_TELEMETRY: "true"
      CHECK_FOR_UPDATES: "false"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8176.
      - "127.0.0.1:8176:3000"
    depends_on:
      postgres:
        condition: service_healthy

  postgrest:
    image: postgrest/postgrest:v12.2.0@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7
    container_name: tooljet-postgrest
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://tooljet:${PG_PASS}@postgres:5432/tooljet_db
      PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}
      PGRST_DB_PRE_CONFIG: postgrest.pre_config
    depends_on:
      postgres:
        condition: service_healthy
    # Restarts until ToolJet's first boot has made tooljet_db and its
    # postgrest.pre_config function. No `ports:` here either.
EOF
cd /srv/tooljet && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, one bind mount.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-tooljet
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# ToolJet · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.tooljet.ai/docs/setup/docker/
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also TOOLJET_HOST in .env.

<DOMAIN> {
	# ToolJet sends a Content-Security-Policy carrying `frame-ancestors *`,
	# which lets any site load this editor in an iframe. This rewrites that
	# one directive with a regular expression and leaves the rest alone.
	header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self'"

	header {
		# Nothing in the container knows it is served over https.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8176 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. reverse_proxy carries the editor's
	# multiplayer WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8176
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-tooljet, reload, and report what it objected to. Caddy asks for the
certificate on the first request and renews it itself, so nothing is scheduled.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8176 stays closed because it is bound to 127.0.0.1, and PostgreSQL and PostgREST publish
no host port at all. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8176, 5432 or 3000.

## 7. Start and verify

The first boot is slow: about 3 GB to pull, then the server waits for PostgreSQL, makes three
databases and migrates before it answers anything.

```bash
cd /srv/tooljet
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/onboarding/signup
docker compose exec -T postgres psql -U tooljet -d tooljet_db -tAc "select count(*) from pg_proc where proname='pre_config'"
```

Assert, all four, and print what you received for each. The loop ends printing `200`. The health
response contains `"works":"yeah"`; it also calls the licence invalid and expired, which is the
community edition answering honestly rather than a fault. The third prints `403`, the security
assert here: open signup is shut before any account exists. The fourth prints `1`, meaning
ToolJet's migrations have made the function PostgREST needs. If any of the four misses, stop, run
`docker compose logs --tail 60 tooljet`, and name the likely cause: a `502` past fifteen minutes
points at step 4, a certificate error at step 5, and a server stuck at `wait-for-it` means step
3's password does not match a volume left from an earlier attempt. A running container is not
success.

The first screen at https://<DOMAIN> is the setup form, headed `Set up your admin account`, over
fields for `Name`, `Email` and a password and a `Sign up` button. The browser draws that heading,
which is why the asserts above go to the API rather than grepping the page.

STOP: tell the user to open https://<DOMAIN>, fill that form in, and wait. Do not continue until
they confirm. It creates the one administrator this install has. Tell them to put the password in
their password manager as they type it: there is no mail server here, so there is no reset link.

Once they confirm, prove the door shut behind them:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/onboarding/setup-super-admin
```

Assert: `403`. Before the account existed that command answered `400`, because the body was empty
rather than because the door was closed, so the move from `400` to `403` is the proof. Both
asserts must pass before you report success.

## 8. First backup and restore

Two artifacts. The database holds every app, query, datasource and user; the config archive holds
the files that rebuild the service around them and the key that decrypts the credentials.

```bash
cd /srv/tooljet
docker compose exec -T postgres pg_dumpall -U tooljet | gzip > /srv/tooljet/backups/tooljet-db-$(date +%F).sql.gz
sudo tar -czf /srv/tooljet/backups/tooljet-config-$(date +%F).tar.gz -C /srv/tooljet compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/tooljet/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. `pg_dumpall` rather than
`pg_dump`, because there are three databases in there and the ToolJet Database is one. Nothing is
stopped: the dump snapshots a running database consistently.

A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/tooljet
scp vps:/srv/tooljet/backups/* ~/backups/tooljet/
```

To restore: `docker compose down`, `sudo rm -rf /srv/tooljet/postgres`, recreate it as in step 2,
untar the config archive into /srv/tooljet, `docker compose up -d postgres`, wait 30 seconds for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U tooljet -d postgres`, then `docker compose up -d`. Tell
the user the stakes: `LOCKBOX_MASTER_KEY` in that `.env` decrypts every datasource credential in
the dump, so a restore beside a fresh key gives back every app and not one working connection.

## 9. Updating later

New versions are listed at https://github.com/ToolJet/ToolJet/releases. Stay on the `-lts` line;
`-beta` tags are the pre-release channel upstream advises against for real use. Take both backups
first, then edit the ToolJet image line in /srv/tooljet/compose.yml to the new tag and digest:

```bash
cd /srv/tooljet
docker compose pull
docker compose up -d
docker compose logs --tail 40 tooljet
```

ToolJet migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's health check before calling the update done.

## 10. What will probably go wrong

PostgREST. For the first few minutes of the first boot it exits and restarts every few seconds
while everything else looks fine. I read that log, saw a connection error naming a database that
did not exist, and checked the password three times. Nothing was wrong: ToolJet makes
`tooljet_db` and the `postgrest.pre_config` function inside it during its own first boot, and
PostgREST cannot start until both exist. The fourth assert in step 7 tells you it has settled. If
it still restarts after that assert prints `1`, `docker compose logs --tail 30 postgrest` will
say why.

## 11. Out of scope

- Do not set `TJ_LICENSE` and do not switch to the `tooljet/tooljet-ee` image. This prompt
  installs the community edition, the AGPL-3.0 one.
- Do not configure SMTP. ToolJet builds and serves apps without it; the cost is invitation and
  password-reset email, a trade the user makes later.
- Do not add a Redis service or set `WORKER=true`. Those belong to the multi-worker workflow
  deployment; this install runs one server with the Redis its own image starts.
- Do not set `ENABLE_CORS` or `ENABLE_CUSTOM_DOMAINS`. The first opens the API to every origin,
  the second changes the cookie policy this install depends on.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing ToolJet v3.20.208-lts, the community edition, on a VPS where Prompt Zero is
done: `ssh vps` works, Docker and Caddy are installed, the firewall is default-deny. Run
everything over `ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname
whose A record already points at the box.

Read this before step 1. `<DOMAIN>` becomes `TOOLJET_HOST`, the address the server compares
request origins against and the one every link it builds carries. Changing it later means editing
a file and recreating containers, so pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64`, and your server's IP
on the last line.

If you do not: `arm64` on the third line is the one that ends the install here. ToolJet publishes
its community-edition image for amd64 only, and a Linux VPS has no emulation layer to fall back
on, so rebuild the box on an amd64 plan rather than trying to force the pull. An empty last line
means the A record does not exist yet: add it, wait a minute, run `dig +short <DOMAIN>` again,
because Caddy cannot get a certificate for a hostname that does not resolve and failed attempts
count against a rate limit you cannot see. Under 4096 MB of RAM, resize the box rather than
continuing. Upstream sizes the application machine at 4 GB and a separate database machine at
8 GB, and this install puts both on one box, so 4 GB is where it starts rather than where it is
comfortable.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/tooljet /srv/tooljet/backups
sudo install -d -m 700 /srv/tooljet/postgres
ls -la /srv/tooljet
```

You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. There is no directory for ToolJet itself, because every app, query, datasource and
user is a row in that database.

## 3. Secrets

Four secrets: the lockbox master key that encrypts datasource credentials, the application secret
key that signs sessions, the PostgreSQL password and the PostgREST JWT secret. All four are
generated here, on the server, and all four go straight into a file only you can read. Replace
`<DOMAIN>` on the first line before you paste.

```bash
umask 077
cat > /srv/tooljet/.env <<EOF
TOOLJET_HOST=https://<DOMAIN>
LOCKBOX_MASTER_KEY=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 64)
PG_PASS=$(openssl rand -hex 32)
PGRST_JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/tooljet/.env
umask 022
ls -l /srv/tooljet/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. The lengths are the ones
upstream documents for each variable.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/tooljet/.env` and carry on.
If the file already existed from an earlier attempt, this block has now overwritten all four,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed `PG_PASS` on an existing volume shows up as the ToolJet
container sitting at `wait-for-it` rather than as anything about passwords.

Read `LOCKBOX_MASTER_KEY` once, with `sudo grep LOCKBOX_MASTER_KEY /srv/tooljet/.env`, and put it
in your password manager tonight. It encrypts every database password, API key and token you hand
to a datasource, so a database restored without it comes back with every app intact and nothing
able to connect.

Do not paste that file, any of those four values, or any command output containing them into this
chat window. No human ever signs in with any of them: the password you choose in step 7, in a
browser, is the only one you type again.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/tooljet/compose.yml <<'EOF'
# ToolJet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://docs.tooljet.ai/docs/setup/docker/
#   variable reference . https://docs.tooljet.ai/docs/setup/env-vars/
#   sizing ............. https://docs.tooljet.ai/docs/setup/system-requirements/
#   tooljet database ... https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# Upstream's in-built-PostgreSQL deployment in our layout: the ToolJet server,
# one PostgreSQL holding the three databases it makes for itself, and the
# PostgREST the ToolJet Database is read through. No Redis service: the -ce
# image starts one in its own container. -ce is the community edition, the tree
# AGPL-3.0 covers; the paid half sits in two private git submodules. Digests
# read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: tooljet-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: tooljet_production
      POSTGRES_USER: tooljet
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - /srv/tooljet/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tooljet -d tooljet_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  tooljet:
    image: tooljet/tooljet-ce:v3.20.208-lts@sha256:78cb01a47c2a0f5efde54ebf2ff3d4c704c1523e1a6b497df65697028701f3c9
    container_name: tooljet
    restart: unless-stopped
    # amd64 only, named rather than guessed. PORT is 3000, not 80, because
    # this image runs as a non-root user.
    platform: linux/amd64
    env_file: /srv/tooljet/.env
    command: ["npm", "run", "start:prod"]
    environment:
      SERVE_CLIENT: "true"
      PORT: "3000"
      # Three databases are made on the first boot.
      PG_HOST: postgres
      PG_USER: tooljet
      PG_DB: tooljet_production
      TOOLJET_DB_HOST: postgres
      TOOLJET_DB_USER: tooljet
      TOOLJET_DB_PASS: ${PG_PASS}
      TOOLJET_DB: tooljet_db
      PGRST_HOST: http://postgrest:3000
      # No browser makes an account except the first administrator's, and
      # neither of the next two phones home, which upstream ships them doing.
      DISABLE_SIGNUPS: "true"
      DISABLE_TOOLJET_TELEMETRY: "true"
      CHECK_FOR_UPDATES: "false"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8176.
      - "127.0.0.1:8176:3000"
    depends_on:
      postgres:
        condition: service_healthy

  postgrest:
    image: postgrest/postgrest:v12.2.0@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7
    container_name: tooljet-postgrest
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://tooljet:${PG_PASS}@postgres:5432/tooljet_db
      PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}
      PGRST_DB_PRE_CONFIG: postgrest.pre_config
    depends_on:
      postgres:
        condition: service_healthy
    # Restarts until ToolJet's first boot has made tooljet_db and its
    # postgrest.pre_config function. No `ports:` here either.
EOF
cd /srv/tooljet && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/tooljet/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/tooljet/compose.yml` and paste again in one go. The `postgrest` service is not
optional scenery. It is what turns the ToolJet Database, the built-in place you can keep tables
without connecting an outside database, into the REST API the builder reads it through. There is
no Redis service because the community-edition image starts one inside its own container.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-tooljet
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# ToolJet · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.tooljet.ai/docs/setup/docker/
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also TOOLJET_HOST in .env.

<DOMAIN> {
	# ToolJet sends a Content-Security-Policy carrying `frame-ancestors *`,
	# which lets any site load this editor in an iframe. This rewrites that
	# one directive with a regular expression and leaves the rest alone.
	header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self'"

	header {
		# Nothing in the container knows it is served over https.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8176 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. reverse_proxy carries the editor's
	# multiplayer WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8176
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-tooljet /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the other.
Caddy asks for the certificate on the first request and renews it itself, so there is nothing here
to schedule. The three-argument `header` line is a find-and-replace on the policy ToolJet sends:
its own Content-Security-Policy carries `frame-ancestors *`, which would let any site on the
internet load your editor and your apps in an iframe, and this narrows that one directive to your
own origin without touching the rest.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8176`, `5432` or `3000`.

If you do not: delete anything for those three with `sudo ufw delete allow 8176`. 8176 is bound to
127.0.0.1 by the compose file, and PostgreSQL and PostgREST publish no host port at all, unlike
upstream's own example compose file which publishes the application on the host's port 80. 80/tcp
is there to redirect to HTTPS and to answer the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3, which Caddy offers by default. `Status: inactive` is a different problem:
Prompt Zero left this firewall enabled, so something has turned it off since, and `sudo ufw enable`
puts it back before you go further.

## 7. Start and verify

The first boot is slow. The image is about 3 GB, then the server waits for PostgreSQL, creates
three databases and runs its migrations before it answers anything.

```bash
cd /srv/tooljet
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/onboarding/signup
docker compose exec -T postgres psql -U tooljet -d tooljet_db -tAc "select count(*) from pg_proc where proname='pre_config'"
```

You should see, in order: the loop reaching `200`, then a small JSON object containing
`"works":"yeah"`, then `403`, then `1`.

If you do not: the `403` is the one worth understanding. It means open signup is refused, which is
what this install wants, and it is the security check in this block. A `201` there would mean
anyone on the internet can create an account on your instance. The JSON from the health endpoint
also reports the licence as invalid and expired: that is the community edition answering honestly,
not a fault, and nothing here needs a licence key. If the loop never reaches `200`, run
`docker compose logs --tail 40 postgres` first, because a database that never reports healthy is
step 2 done wrong, and `docker compose logs --tail 60 tooljet` second. A ToolJet log that sits on
`wait-for-it` is step 3's password not matching a database volume left from an earlier attempt. A
running container is not success.

If the last command printed `0` instead of `1`, ToolJet has not finished creating the ToolJet
Database yet, and PostgREST will be restarting in a loop until it does. Wait two minutes and run
that one line again.

The first screen at https://<DOMAIN> is the setup form, headed `Set up your admin account`, over
fields for `Name`, `Email` and a password and a `Sign up` button. Your browser draws that heading,
which is why the checks above go to the API instead.

Open https://<DOMAIN> in a browser now and fill that form in. It runs once, and it makes the only
administrator this install has. Put the password in your password manager as you type it, because
there is no SMTP configured here and therefore no password-reset email to fall back on.

Then prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/onboarding/setup-super-admin
```

You should see: `403`.

If you do not: a `400` means the first-account form has not been completed, because that endpoint
answers `400` on an empty body while it is still open and `403` once an administrator exists. Go
back to the browser and finish it. That form is the one moment this install would accept an
administrator from anyone who could reach the URL, and closing it is the point of this check.

## 8. First backup and restore

Two artifacts. The database holds every app, query, datasource and user. The config archive holds
the files that rebuild the service around them, and the key that decrypts the credentials in it.

```bash
cd /srv/tooljet
docker compose exec -T postgres pg_dumpall -U tooljet | gzip > /srv/tooljet/backups/tooljet-db-$(date +%F).sql.gz
sudo tar -czf /srv/tooljet/backups/tooljet-config-$(date +%F).tar.gz -C /srv/tooljet compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/tooljet/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline, because
the dump snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dumpall` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
`pg_dumpall` rather than `pg_dump` on purpose: there are three databases in that container, and
the ToolJet Database is one of them.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/tooljet
scp vps:/srv/tooljet/backups/* ~/backups/tooljet/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/tooljet/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty install:

```bash
cd /srv/tooljet
docker compose down
sudo rm -rf /srv/tooljet/postgres
sudo install -d -m 700 /srv/tooljet/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/tooljet/backups/tooljet-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U tooljet -d postgres
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/health
```

You should see: `CREATE DATABASE`, `CREATE TABLE` and `COPY` lines from psql, then `200` from the
last command, and your administrator account still works when you sign in.

If you do not: `role "tooljet" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the config archive
is for before you skip it: it carries `.env`, and `LOCKBOX_MASTER_KEY` in that file is what
decrypts every datasource credential in the dump. Restore a database next to a freshly generated
key and you get back every app you built and not one working connection.

## 9. Updating later

New versions are listed at https://github.com/ToolJet/ToolJet/releases. Stay on the `-lts` line:
tags ending `-beta` are the pre-release channel and upstream advises against them for real use.
Take both backup artifacts first, then edit the ToolJet image line in /srv/tooljet/compose.yml to
the new tag and its digest.

```bash
cd /srv/tooljet
docker compose pull
docker compose up -d
docker compose logs --tail 40 tooljet
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one of your apps as well,
because a server that answers `"works":"yeah"` can still be failing on a migration that stopped
halfway.

## 10. What will probably go wrong

PostgREST. For the first few minutes of the very first boot it exits and restarts every few
seconds, and `docker compose ps` shows it flapping while everything else looks fine. I read that
log, saw a connection error naming a database that did not exist, and checked the password three
times. Nothing was wrong: ToolJet makes `tooljet_db` and the `postgrest.pre_config` function
inside it during its own first boot, and PostgREST cannot start until both exist. The fourth check
in step 7 is what tells you it has settled. If it still restarts after that check prints `1`,
`docker compose logs --tail 30 postgrest` will say why.

## 11. Out of scope

- Do not set `TJ_LICENSE` and do not switch to the `tooljet/tooljet-ee` image. This install is the
  community edition, the AGPL-3.0 one.
- Do not configure SMTP. ToolJet builds and serves apps without it; the cost is invitation and
  password-reset email, a trade you make later.
- Do not add a Redis service or set `WORKER=true`. Those belong to the multi-worker workflow
  deployment; this install runs one server with the Redis its own image starts.
- Do not set `ENABLE_CORS` or `ENABLE_CUSTOM_DOMAINS`. The first opens the API to every origin,
  the second changes the cookie policy this install depends on.

330 lines · 14,999 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install ToolJet v3.20.208-lts, with the PostgreSQL it keeps everything in, under
~/selfhost/tooljet, answering at http://localhost:8176.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
ToolJet builds internal tools for other people to use, and here the only address those tools have
is http://localhost:8176, which means "this computer" wherever it is read. The user gets the
builder; the colleague they meant to hand a form gets a connection error.

Detect the OS and measure:

```bash
uname -s
uname -m
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. It needs 4096 MB of RAM available and 20 GB
free on the home disk, and Docker Desktop's virtual machine takes its allocation out of the figure
printed. Under either floor, print both and stop. The image is amd64 only: on macOS
`arm64` is fine and slower under emulation, but stop on a `Linux` printing `aarch64`.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/tooljet/backups
ls -la ~/selfhost/tooljet
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: every app and
user is a row in the PostgreSQL that step 5 keeps in a Docker volume.

## 4. Secrets

Four secrets, at the lengths upstream documents: the lockbox master key, the application secret
key, the PostgreSQL password and the PostgREST JWT secret. Generate all four here, print none,
and keep them out of your summary and any log.

```bash
umask 077
cat > ~/selfhost/tooljet/.env <<EOF
TOOLJET_HOST=http://localhost:8176
LOCKBOX_MASTER_KEY=$(openssl rand -hex 32)
SECRET_KEY_BASE=$(openssl rand -hex 64)
PG_PASS=$(openssl rand -hex 32)
PGRST_JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/tooljet/.env
umask 022
ls -l ~/selfhost/tooljet/.env
```

Assert: mode `-rw-------`; Git Bash ships openssl, so these lines run the same on all three
systems. Tell the user to copy `LOCKBOX_MASTER_KEY` into their password manager tonight, reading
it with `grep LOCKBOX_MASTER_KEY ~/selfhost/tooljet/.env`: it encrypts every datasource
credential, so a restore without it gives back every app and no working connection. On Windows
those mode bits are advisory; the real boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/tooljet/compose.yml <<'EOF'
# ToolJet · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://docs.tooljet.ai/docs/setup/docker/
#   variable reference . https://docs.tooljet.ai/docs/setup/env-vars/
#   sizing ............. https://docs.tooljet.ai/docs/setup/system-requirements/
#   tooljet database ... https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# The ToolJet server, one PostgreSQL holding the three databases it makes for
# itself, and the PostgREST the ToolJet Database is read through. No Redis
# service: the -ce community-edition image starts one in its own container. The
# database is a named volume because PostgreSQL chowns it to a uid Windows file
# sharing will not grant on a home folder. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: tooljet-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: tooljet_production
      POSTGRES_USER: tooljet
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - tooljet-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tooljet -d tooljet_production"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  tooljet:
    image: tooljet/tooljet-ce:v3.20.208-lts@sha256:78cb01a47c2a0f5efde54ebf2ff3d4c704c1523e1a6b497df65697028701f3c9
    container_name: tooljet
    restart: unless-stopped
    # amd64 only, so an Apple Silicon Mac emulates it, slower but working.
    platform: linux/amd64
    env_file: ./.env
    command: ["npm", "run", "start:prod"]
    environment:
      SERVE_CLIENT: "true"
      PORT: "3000"
      # Three databases are made on the first boot.
      PG_HOST: postgres
      PG_USER: tooljet
      PG_DB: tooljet_production
      TOOLJET_DB_HOST: postgres
      TOOLJET_DB_USER: tooljet
      TOOLJET_DB_PASS: ${PG_PASS}
      TOOLJET_DB: tooljet_db
      PGRST_HOST: http://postgrest:3000
      # No browser makes an account except the first administrator's, and
      # neither of the next two phones home, which upstream ships them doing.
      DISABLE_SIGNUPS: "true"
      DISABLE_TOOLJET_TELEMETRY: "true"
      CHECK_FOR_UPDATES: "false"
    ports:
      # Loopback only: no other device on the wifi can reach 8176.
      - "127.0.0.1:8176:3000"
    depends_on:
      postgres:
        condition: service_healthy

  postgrest:
    image: postgrest/postgrest:v12.2.0@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7
    container_name: tooljet-postgrest
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://tooljet:${PG_PASS}@postgres:5432/tooljet_db
      PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}
      PGRST_DB_PRE_CONFIG: postgrest.pre_config
    depends_on:
      postgres:
        condition: service_healthy
    # Restarts until ToolJet's first boot has made tooljet_db and its
    # postgrest.pre_config function. No `ports:` here either.

volumes:
  tooljet-pgdata:
EOF
cd ~/selfhost/tooljet && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Three services, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, no public name to
certify, nothing published beyond loopback to close. Browsers treat http://localhost as a secure
context, so the editor's crypto still works.

8176 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. For a tool whose apps get handed to others, that is the trade. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/tooljet/compose.yml
```

Assert: `1`. PostgreSQL and PostgREST publish no host port, so neither appears.

## 7. Start and verify

About 3 GB to pull, then three databases are made and migrated before it answers.

```bash
cd ~/selfhost/tooljet
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8176/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8176/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8176/api/onboarding/signup
docker compose exec -T postgres psql -U tooljet -d tooljet_db -tAc "select count(*) from pg_proc where proname='pre_config'"
```

Assert all four and print what you got: the loop ends on `200`; the health body contains
`"works":"yeah"`, and calls the licence expired, which the community edition does honestly; the
third prints `403`, the security assert here, because open signup is shut before any account
exists; the fourth prints `1`, the function PostgREST needs. If any misses, stop and run
`docker compose logs --tail 60 tooljet`: a container restarting in a loop is usually Docker
Desktop's memory cap, and `port is already allocated` means something else holds 8176
(`lsof -nP -iTCP:8176 -sTCP:LISTEN`, or `netstat -ano | findstr :8176` on Windows). A running
container is not success.

The first screen at http://localhost:8176 is the setup form, headed `Set up your admin account`,
over `Name`, `Email`, a password and a `Sign up` button. The browser draws that heading, which is
why the asserts go to the API rather than the page.

STOP: tell the user to open http://localhost:8176, fill that form in, and wait. Do not continue
until they confirm. It makes the only administrator here, and with no mail there is no reset, so
have them save the password as they type.

Once they confirm, prove the door shut behind them:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8176/api/onboarding/setup-super-admin
```

Assert: `403`. Before the account existed it answered `400`, on the empty body rather than on a
closed door, so the move from `400` to `403` is the proof.

## 8. First backup and restore

Two artifacts: a dump with every app, query, datasource and user, and a config archive with the
two files that rebuild the service around it.

```bash
cd ~/selfhost/tooljet
docker compose exec -T postgres pg_dumpall -U tooljet | gzip > ~/selfhost/tooljet/backups/tooljet-db-$(date +%F).sql.gz
tar -C ~/selfhost/tooljet -czf ~/selfhost/tooljet/backups/tooljet-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/tooljet/backups/
```

Assert: both exist and are non-empty. Print both sizes. `pg_dumpall` rather than `pg_dump`,
because there are three databases and the ToolJet Database is one of them.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB stick,
and copy both there with `cp`. Assert: the user confirms both filenames are listed there. If not,
say plainly that this install has no backup.

To restore: `cd ~/selfhost/tooljet` and untar the config archive first, so .env is back before
any container starts. PostgreSQL takes `PG_PASS` from it the moment it initialises an empty
volume, and `LOCKBOX_MASTER_KEY` in the same file decrypts the credentials in the dump. Then
`docker compose down -v`, which drops the old volume deliberately, then
`docker compose up -d postgres`, wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U tooljet -d postgres`, then `docker compose up -d`.

## 9. Updating later

New versions are at https://github.com/ToolJet/ToolJet/releases. Stay on the `-lts` line; `-beta`
tags are the pre-release channel upstream advises against. Back up first, then edit the ToolJet
image line in ~/selfhost/tooljet/compose.yml to the new tag and digest.

```bash
cd ~/selfhost/tooljet
docker compose pull
docker compose up -d
docker compose logs --tail 40 tooljet
```

Watch it until the migrations settle, then re-run step 7's health check.

## 10. What will probably go wrong

The wait, on an Apple Silicon Mac. I brought this up on an M-series laptop with plenty of memory,
watched `curl` return nothing for eleven minutes, and started taking the compose file apart
looking for a mistake that was not there. The image is amd64 only, so Docker Desktop was
translating every instruction, and the first boot takes several times what it does on an Intel
box. The loop in step 7 waits fifteen minutes on purpose: let it run and watch
`docker compose logs -f tooljet`.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8176 to 0.0.0.0 so a colleague on the wifi can open an app. That publishes an
  internal-tools builder, and its saved credentials, onto every network this machine joins.
- Do not set `TJ_LICENSE` or switch to the `tooljet/tooljet-ee` image. This prompt installs the
  community edition, the AGPL-3.0 one.
- Do not configure SMTP, add a Redis service, or set `WORKER=true`. Mail buys invites and resets
  a single-user install does not need; the extra Redis belongs to the workflow deployment.
compose.local.ymlthe services, pinned · local layout80 lines

authored from upstream docs, never pasted · 3,216 bytes

# ToolJet · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://docs.tooljet.ai/docs/setup/docker/
#   variable reference . https://docs.tooljet.ai/docs/setup/env-vars/
#   sizing ............. https://docs.tooljet.ai/docs/setup/system-requirements/
#   tooljet database ... https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# The ToolJet server, one PostgreSQL holding the three databases it makes for
# itself, and the PostgREST the ToolJet Database is read through. No Redis
# service: the -ce community-edition image starts one in its own container. The
# database is a named volume because PostgreSQL chowns it to a uid Windows file
# sharing will not grant on a home folder. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: tooljet-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: tooljet_production
      POSTGRES_USER: tooljet
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - tooljet-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tooljet -d tooljet_production"]
      interval: 10s
      retries: 12
    # No `ports:`: 5432 is reachable only from the other containers.

  tooljet:
    image: tooljet/tooljet-ce:v3.20.208-lts@sha256:78cb01a47c2a0f5efde54ebf2ff3d4c704c1523e1a6b497df65697028701f3c9
    container_name: tooljet
    restart: unless-stopped
    # amd64 only, so an Apple Silicon Mac emulates it, slower but working.
    platform: linux/amd64
    env_file: ./.env
    command: ["npm", "run", "start:prod"]
    environment:
      SERVE_CLIENT: "true"
      PORT: "3000"
      # Three databases are made on the first boot.
      PG_HOST: postgres
      PG_USER: tooljet
      PG_DB: tooljet_production
      TOOLJET_DB_HOST: postgres
      TOOLJET_DB_USER: tooljet
      TOOLJET_DB_PASS: ${PG_PASS}
      TOOLJET_DB: tooljet_db
      PGRST_HOST: http://postgrest:3000
      # No browser makes an account except the first administrator's, and
      # neither of the next two phones home, which upstream ships them doing.
      DISABLE_SIGNUPS: "true"
      DISABLE_TOOLJET_TELEMETRY: "true"
      CHECK_FOR_UPDATES: "false"
    ports:
      # Loopback only: no other device on the wifi can reach 8176.
      - "127.0.0.1:8176:3000"
    depends_on:
      postgres:
        condition: service_healthy

  postgrest:
    image: postgrest/postgrest:v12.2.0@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7
    container_name: tooljet-postgrest
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://tooljet:${PG_PASS}@postgres:5432/tooljet_db
      PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}
      PGRST_DB_PRE_CONFIG: postgrest.pre_config
    depends_on:
      postgres:
        condition: service_healthy
    # Restarts until ToolJet's first boot has made tooljet_db and its
    # postgrest.pre_config function. No `ports:` here either.

volumes:
  tooljet-pgdata:

agent-readable mirror: /self-host/tooljet-cloud.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned79 lines

authored from upstream docs, never pasted · 3,270 bytes

# ToolJet · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://docs.tooljet.ai/docs/setup/docker/
#   variable reference . https://docs.tooljet.ai/docs/setup/env-vars/
#   sizing ............. https://docs.tooljet.ai/docs/setup/system-requirements/
#   tooljet database ... https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# Upstream's in-built-PostgreSQL deployment in our layout: the ToolJet server,
# one PostgreSQL holding the three databases it makes for itself, and the
# PostgREST the ToolJet Database is read through. No Redis service: the -ce
# image starts one in its own container. -ce is the community edition, the tree
# AGPL-3.0 covers; the paid half sits in two private git submodules. Digests
# read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: tooljet-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: tooljet_production
      POSTGRES_USER: tooljet
      POSTGRES_PASSWORD: ${PG_PASS}
    volumes:
      - /srv/tooljet/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tooljet -d tooljet_production"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  tooljet:
    image: tooljet/tooljet-ce:v3.20.208-lts@sha256:78cb01a47c2a0f5efde54ebf2ff3d4c704c1523e1a6b497df65697028701f3c9
    container_name: tooljet
    restart: unless-stopped
    # amd64 only, named rather than guessed. PORT is 3000, not 80, because
    # this image runs as a non-root user.
    platform: linux/amd64
    env_file: /srv/tooljet/.env
    command: ["npm", "run", "start:prod"]
    environment:
      SERVE_CLIENT: "true"
      PORT: "3000"
      # Three databases are made on the first boot.
      PG_HOST: postgres
      PG_USER: tooljet
      PG_DB: tooljet_production
      TOOLJET_DB_HOST: postgres
      TOOLJET_DB_USER: tooljet
      TOOLJET_DB_PASS: ${PG_PASS}
      TOOLJET_DB: tooljet_db
      PGRST_HOST: http://postgrest:3000
      # No browser makes an account except the first administrator's, and
      # neither of the next two phones home, which upstream ships them doing.
      DISABLE_SIGNUPS: "true"
      DISABLE_TOOLJET_TELEMETRY: "true"
      CHECK_FOR_UPDATES: "false"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8176.
      - "127.0.0.1:8176:3000"
    depends_on:
      postgres:
        condition: service_healthy

  postgrest:
    image: postgrest/postgrest:v12.2.0@sha256:2cf1efd2c9c2e7606610c113cc73e936d8ce9ba089271cb9cbf11aa564bc30c7
    container_name: tooljet-postgrest
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://tooljet:${PG_PASS}@postgres:5432/tooljet_db
      PGRST_JWT_SECRET: ${PGRST_JWT_SECRET}
      PGRST_DB_PRE_CONFIG: postgrest.pre_config
    depends_on:
      postgres:
        condition: service_healthy
    # Restarts until ToolJet's first boot has made tooljet_db and its
    # postgrest.pre_config function. No `ports:` here either.
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,122 bytes

# ToolJet · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://docs.tooljet.ai/docs/setup/docker/
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also TOOLJET_HOST in .env.

<DOMAIN> {
	# ToolJet sends a Content-Security-Policy carrying `frame-ancestors *`,
	# which lets any site load this editor in an iframe. This rewrites that
	# one directive with a regular expression and leaves the rest alone.
	header Content-Security-Policy "frame-ancestors [^;]+" "frame-ancestors 'self'"

	header {
		# Nothing in the container knows it is served over https.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8176 is the loopback port compose publishes here, not a container port
	# and not open in the firewall. reverse_proxy carries the editor's
	# multiplayer WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8176
}
install.shthe same install, no agent175 lines

authored from upstream docs, never pasted · 8,069 bytes

#!/usr/bin/env bash
# ToolJet · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=tools.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.tooljet.ai/docs/setup/docker/
#   https://docs.tooljet.ai/docs/setup/env-vars/
#   https://docs.tooljet.ai/docs/setup/system-requirements/
#   https://docs.tooljet.ai/docs/tooljet-db/tooljet-database/
#
# Four secrets are generated here, on this machine: the lockbox master key, the
# application secret key, the PostgreSQL password and the PostgREST JWT secret.
# All four go into /srv/tooljet/.env with mode 600 and none is ever printed.
# LOCKBOX_MASTER_KEY is the one that matters at 2am: it encrypts every
# datasource credential, so a database restored without it comes back with
# every app intact and nothing able to connect.
#
# DOMAIN_HOST is also TOOLJET_HOST, the address the server compares request
# origins against. Choose it once.
#
# This script stops one step short of a usable install on purpose: only a human
# in a browser can fill in the one-time form that creates the administrator
# account. The closing summary says where.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/tooljet}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. tools.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this machine reports ${arch}; ToolJet publishes its community-edition image for amd64 only"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; the server plus PostgreSQL wants 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# postgres stays root-owned at 700: the PostgreSQL image chowns its own data
# directory on first start and refuses one that has been chowned already.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Hex at the lengths upstream documents for each. Read the lockbox key later
# with
#   sudo grep LOCKBOX_MASTER_KEY /srv/tooljet/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		TOOLJET_HOST=https://${DOMAIN_HOST}
		LOCKBOX_MASTER_KEY=$(openssl rand -hex 32)
		SECRET_KEY_BASE=$(openssl rand -hex 64)
		PG_PASS=$(openssl rand -hex 32)
		PGRST_JWT_SECRET=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-tooljet"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8176, 5432 and 3000 are none of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8176, 5432 and 3000 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first boot pulls about 3 GB, waits for PostgreSQL, creates three
# databases and runs the migrations before anything answers. PostgREST restarts
# in a loop until the tooljet_db database and its postgrest.pre_config function
# exist; that loop ends on its own.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 60 tooljet"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"works":"yeah"' \
	|| die "/api/health answered 200 without the expected body. Check: docker compose logs --tail 60 tooljet"

# Open signup must be refused before any account exists. DISABLE_SIGNUPS is set
# in compose.yml and the guard on this endpoint reads it on every request.
signup="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/onboarding/signup" || true)"
[ "$signup" = "403" ] || die "POST /api/onboarding/signup returned ${signup}, not 403. Stop and investigate."

# The ToolJet Database is wired up once the migrations have made the function
# PostgREST needs.
echo "==> waiting for the ToolJet Database to finish setting itself up"
for _ in $(seq 1 20); do
	fn="$(docker compose exec -T postgres psql -U tooljet -d tooljet_db -tAc "select count(*) from pg_proc where proname='pre_config'" 2>/dev/null | tr -dc '0-9' || true)"
	[ "${fn:-0}" = "1" ] && break
	sleep 15
done
[ "${fn:-0}" = "1" ] || die "postgrest.pre_config was never created. Check: docker compose logs --tail 40 tooljet"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dumpall -U tooljet | gzip > "$APP_DIR/backups/tooljet-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/tooljet-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/tooljet-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	ToolJet is answering at https://${DOMAIN_HOST}/api/health

	  1. One step is left and only you can do it. Open
	       https://${DOMAIN_HOST}
	     and fill in the form headed "Set up your admin account". It makes the
	     only administrator this install has and then refuses to run again.
	     Put that password in your password manager as you type it: this
	     install configures no mail, so there is no reset email.
	  2. Open signup is off, and this script checked it: an unauthenticated
	     POST to /api/onboarding/signup answers 403.
	  3. Four secrets live in $APP_DIR/.env, mode 600, none printed here. The
	     one to keep is the lockbox key, readable with
	       sudo grep LOCKBOX_MASTER_KEY $APP_DIR/.env
	     It decrypts every datasource credential you save.
	  4. The health endpoint reports the licence as invalid and expired. That
	     is the community edition answering honestly; nothing here needs a key.
	  5. First backup written to $APP_DIR/backups: a pg_dumpall of all three
	     databases and a config archive holding compose.yml, .env and the Caddy
	     site block. They are on the same disk as the data, which is not a
	     backup. Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel ToolJet.

  • The repository you install from is AGPL-3.0, and the paid half is not in it. ToolJet keeps its enterprise code in two private git submodules, so unlike projects that ship a visible enterprise/ directory there is nothing to read and nothing to accidentally build. The split is real: single sign-on, custom user groups and granular permissions, audit logs, white labelling and a custom login domain, Git sync, multi-environment deployment and SCIM provisioning are what the paid plans sell. What you install is uncapped on builders, end users and apps and stops short of that list.
  • No arm64. Upstream publishes the community-edition image for amd64 only, so an arm64 VPS cannot run this at all and the install stops at preflight rather than half way. On an Apple Silicon Mac the local path works through Docker Desktop's emulation, and the first boot takes several times what it does on an Intel machine.
  • One key stands between a backup and every credential in it. LOCKBOX_MASTER_KEY encrypts each database password, API key and token you hand to a datasource, and it lives in .env rather than in the database, so the backup is two artifacts that have to travel together. Restore the dump beside a freshly generated key and every app comes back with no working connection.
  • No mail means no invitations and no password resets. This install configures no SMTP, which the core loop does not need, but the day you add a second person you either configure mail or edit the database by hand, and a forgotten administrator password has no reset link. Put it in a password manager on day one.
  • Three containers and one PostgreSQL holding three databases: the application's, the ToolJet Database your tables live in, and a sample one the server seeds. The backup is a pg_dumpall rather than a pg_dump for exactly that reason, and PostgREST spends the first minutes of a fresh install restarting until the migrations have made the function it needs.

Where this came from

“We highly recommend using LTS versions for most users. These versions are prioritized for bug fixes, updates, and overall stability, ensuring a reliable experience.”

  • Upstream's own in-built-PostgreSQL compose file runs three services: the ToolJet server, PostgreSQL, and the PostgREST that serves the ToolJet Database. source
  • The community-edition image starts a Redis of its own inside the container, bound to that container's loopback, so a single-instance install needs no separate Redis service. source
  • LOCKBOX_MASTER_KEY is a 32-byte hex string that encrypts datasource credentials and SECRET_KEY_BASE a 64-byte hex string that encrypts session cookies, and upstream documents openssl rand as the way to make both. source
  • The paid half of ToolJet is two git submodules, ToolJet/ee-frontend and ToolJet/ee-server, whose repositories are not public, so the code the AGPL-3.0 licence covers is the community edition and the rest is not published at all. source
  • The server answers /health and /api/health with a body containing "works":"yeah" plus a licence status, which on the community edition reports invalid and expired. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host ToolJet?

    Not ToolJet itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is ToolJet. The internal-tools builder ToolJet sells, installed from ToolJet's own repository, with no per-builder meter and no end-user cap. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces ToolJet?

    ToolJet. The internal-tools builder ToolJet sells, installed from ToolJet's own repository, with no per-builder meter and no end-user cap. The same company writes it, from the same repository, and points its own documentation at the compose file, which makes this one of the rare cases where self-hosting is the vendor's own path rather than a workaround. You get the builder, the query editor, the ToolJet Database and every connector in the marketplace, uncapped on builders, end users and apps, which is the whole per-builder meter gone. What you take on is three containers, one PostgreSQL holding three databases, an image published for amd64 only, and a licence key you never buy: SSO, custom user groups, audit logs, white labelling, Git sync and multi-environment deployment live behind that key in two private submodules and are not in what you install. Also evaluated and ranked below it: Appsmith. The prompt on this page installs ToolJet only. ToolJet is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to ToolJet?

    4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. ToolJet itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: ToolJet Pro, $237/mo — $2,844 a year, 3 seats assumed.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the ToolJet install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run ToolJet on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same ToolJet on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every app you build answers only at http://localhost:8176, so the colleague you meant to hand the form to gets a connection error, and on an Apple Silicon Mac the amd64-only image runs under emulation, which works and is slow. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.