# Can I self-host ToolJet?

**YES** — it's called ToolJet. ONE EVENING setup · ~2 hours to running · 4 GB RAM minimum · $237/mo you stop paying ($2,844/yr on the Pro plan, 3 seats assumed).

ToolJet authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/tooljet-cloud/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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.
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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
```

## Also evaluated

Ranked below ToolJet for this swap. The prompts above install ToolJet only.

- **Appsmith** — 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 page this mirrors: https://caniselfhostit.com/self-host/tooljet-cloud/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
