# Can I self-host Quickbase?

**YES** — it's called Budibase. ONE EVENING setup · ~1.5 hours to running · 6 GB RAM minimum · $175/mo you stop paying ($2,100/yr on the Team plan, 5 seats assumed).

Budibase authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/quickbase/

## 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 Budibase 3.41.3 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until they
answer. `<DOMAIN>` is the hostname whose A record already points at this server.
`<ADMIN_EMAIL>` matters more here than usual: this prompt creates the administrator
from the environment during the first boot, so nobody who finds the hostname first can claim it.
Take it in lowercase and repeat it back.

Budibase needs 6144 MB of RAM available and 20 GB free on /srv. That is upstream's own figure.
This is the all-in-one image: one container runs CouchDB, the Clouseau search indexer, a
Structured Query Server, Redis, MinIO, an internal PostgreSQL and a LiteLLM proxy alongside the
server and worker. The image publishes amd64 and arm64. 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 6144 MB or free disk is under 20 GB, print both numbers and stop. The
pull alone is over a gigabyte, and the OOM killer arrives partway through the first boot, which
reads as random rather than as a decision made at checkout. If `dig +short` prints nothing,
print that and stop too.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/budibase /srv/budibase/backups
sudo install -d -m 755 /srv/budibase/data
ls -la /srv/budibase
```

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by root at mode `755`.
Leave both alone. The container starts as root and then chowns `data/couch` to its CouchDB uid
and `data/litellm` to its PostgreSQL uid, and those processes have to traverse the parent, so a
tighter mode stops the database from starting. Everything the instance keeps lands in there,
including a `.env` of secrets it writes for itself.

## 3. Secrets

Four secrets, all generated on the server. Do not print any of them, do not repeat them in your
summary, and do not put them in a log line. Hex rather than base64: a human types one of them
into a login form.

```bash
umask 077
cat > /srv/budibase/.env <<EOF
BB_ADMIN_USER_EMAIL=<ADMIN_EMAIL>
PLATFORM_URL=https://<DOMAIN>
BB_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
COUCHDB_PASSWORD=$(openssl rand -hex 32)
INTERNAL_API_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/budibase/.env
umask 022
ls -l /srv/budibase/.env
```

Assert: the file exists with mode `-rw-------`. Three of the four close a door rather than open
one. `BB_ADMIN_USER_PASSWORD` is the initial administrator password: the server creates that
account at start-up when both `BB_ADMIN_USER_` values are set on a self-hosted single-tenant
instance. That removes the window where a stranger reaches the hostname and fills in the setup
form first. `COUCHDB_PASSWORD` replaces a credential the base image bakes in as the literal word
`admin`, left alone by the start-up script precisely because it is not empty. `INTERNAL_API_KEY`
rides in the `x-budibase-api-key` header the server and worker call each other with.
`JWT_SECRET` signs session cookies and, with `API_ENCRYPTION_KEY` unset on this shape, is also
the key the platform encrypts stored API keys with.

Tell the user their password is readable with
`sudo grep BB_ADMIN_USER_PASSWORD /srv/budibase/.env` and belongs in their password manager
tonight. Keep the file: compose will not start without it, and step 8 archives it.

## 4. compose.yml

```bash
cat > /srv/budibase/compose.yml <<'EOF'
# Budibase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://docs.budibase.com/docs/docker
#   start-up script .. https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#
# One service, and it is a crowded one. Upstream's all-in-one image runs
# CouchDB, the Clouseau search indexer, a Structured Query Server, Redis,
# MinIO, an internal PostgreSQL and a LiteLLM proxy alongside the Budibase
# server and worker, all under pm2 behind an nginx inside the container. That
# is why no database service appears below, why the RAM floor is 6 GB, and
# why everything the instance keeps, including the .env of generated secrets
# it writes on first boot, lives under the one /data mount.
#
# CUSTOM_DOMAIN is deliberately never set: it makes the container run certbot
# for a certificate of its own, and the host's Caddy already terminates TLS.
# The container's 443 is never published, only its plain-http 80. The image
# ships its own HEALTHCHECK, so none is declared here. Tag and digest read
# from the registry on 2026-08-12; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  budibase:
    image: budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1
    container_name: budibase
    restart: unless-stopped
    env_file: /srv/budibase/.env
    environment:
      # Upstream ships product analytics on for self-hosted instances. The
      # string "0" is the off switch: backend-core coerces "0" and "false"
      # to a disabled value before anything reads it.
      ENABLE_ANALYTICS: "0"
    volumes:
      - /srv/budibase/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8187.
      - "127.0.0.1:8187:80"
EOF
cd /srv/budibase && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, one mount.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site here.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-budibase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Budibase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.budibase.com/docs/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 PLATFORM_URL in .env, the address
# Budibase builds app and invitation links against, so the two have to agree.

<DOMAIN> {
	# The nginx inside the container proxies /db/ straight into the CouchDB
	# that holds every table, row and app here. Upstream documents that path
	# as an operator's route to Fauxton, CouchDB's own admin client:
	# https://docs.budibase.com/docs/accessing-couchdb . That is a tool for
	# whoever runs this box, not a page for the internet, so this refuses it.
	@couchdb path /db/*
	respond @couchdb 403

	# HSTS is the one the container cannot send for itself, because nothing
	# inside it knows it is served over https. No `encode`: the inner nginx
	# already gzips. No X-Frame-Options either, because the application sets
	# frame-ancestors itself from the workspace embed allowlist.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8187 is the loopback port compose publishes on this host. It is not
	# open in the firewall. The builder holds a WebSocket open to /socket/,
	# and reverse_proxy carries that upgrade with no extra configuration.
	reverse_proxy 127.0.0.1:8187
}
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-budibase, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it itself, so nothing needs scheduling.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a Prompt Zero box:

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

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8187 stays closed because it is bound to 127.0.0.1. CouchDB, Redis, MinIO,
PostgreSQL and LiteLLM listen inside the container and are never published, so they have no host
port to firewall. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule for 8187, 5984, 6379, 9000 or 5432.

## 7. Start and verify

The first boot is slow and meant to be: over a gigabyte to pull, then CouchDB's system
databases, PostgreSQL's `initdb` and LiteLLM's migrations before the server and worker start.
Fifteen minutes on a small box is normal.

```bash
cd /srv/budibase
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/system/status
curl -sS https://<DOMAIN>/api/global/configs/checklist | grep -o '"adminUser":{"checked":[a-z]*'
docker compose exec -T budibase curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin http://127.0.0.1:5984/_all_dbs
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/db/_all_dbs
docker compose exec -T budibase sh -c 'chmod 600 /data/.env && stat -c %a /data/.env'
```

Assert, all six, and print what you received for each. The loop ends printing `200`. The status
response contains `"version":"3.41.3"`, the running build agreeing with the pinned tag, not
with whatever a cached layer held. The checklist prints `"adminUser":{"checked":true`, the
security assert here: the administrator existed before the port ever answered a stranger, so
there was no setup form to walk into. The fourth prints `401`, so the CouchDB credential the
base image bakes in is dead. The fifth prints `403`, so Caddy refuses the path that would reach
that database from the internet. The last prints `600`: the container writes that file with its
own umask, and CouchDB and PostgreSQL run in there as uids of their own.

If any of the six misses, stop, run `docker compose logs --tail 80 budibase`, and name the
likely cause: a `502` past fifteen minutes is step 4 or memory, a certificate error is step 5,
and `"adminUser":{"checked":false` means the `BB_ADMIN_USER_` lines never reached the container,
which is step 3. On a `false`, do not open the site and do not create an account by hand. Reset
while nothing is at stake: `docker compose down`, `sudo rm -rf /srv/budibase/data`,
`sudo install -d -m 755 /srv/budibase/data`, confirm both lines are in `.env`, then
`docker compose up -d` and run this block again. A running container is not success.

The first screen at https://<DOMAIN> is the sign-in form, headed `Log in to Budibase`, not the
`Create an admin user` screen. That difference is the whole point of step 3.

STOP: tell the user to read their password with
`sudo grep BB_ADMIN_USER_PASSWORD /srv/budibase/.env`, sign in at https://<DOMAIN> with
`<ADMIN_EMAIL>`, change the password in their account settings, and wait.
Do not continue until they confirm they are signed in. There is no mail server here, so there is
no reset link: the password they set now is the only way back in.

## 8. First backup and restore

One archive, taken with the container stopped. Several storage engines write in there, and a
tar of a live CouchDB is not a backup but a file that resembles one.

```bash
cd /srv/budibase
docker compose stop
sudo tar -czf /srv/budibase/backups/budibase-$(date +%F).tar.gz -C /srv/budibase compose.yml .env data -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/budibase/backups/
```

Assert: the archive exists and is non-empty. Print its size. The stop and start cost several
minutes while the container brings every engine back up. Upstream sells in-product workspace
backups as a licensed feature, so this archive is the backup here.

A backup on the same disk is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/budibase
scp vps:/srv/budibase/backups/*.tar.gz ~/backups/budibase/
```

To restore: `docker compose down`, `sudo rm -rf /srv/budibase/data`,
`sudo tar -xzf /srv/budibase/backups/<archive> -C /srv/budibase`, `docker compose up -d`, then
re-run step 7's checks. Untar with sudo, always: the archive carries the uids CouchDB and
PostgreSQL own their directories as, and flattening those owners gives a container that starts
and databases that do not. `.env` is in the archive on purpose and goes back before the first
start: data restored beside a fresh `JWT_SECRET` signs every session out and cannot decrypt the
API keys the old one encrypted. Those four commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/Budibase/budibase/releases. 3.41.3 was the newest
stable release on the day this was pinned. Take step 8's backup first, then edit the image line
in /srv/budibase/compose.yml to the new tag and digest:

```bash
cd /srv/budibase
docker compose pull
docker compose up -d
docker compose logs --tail 60 budibase
```

Budibase migrates its own databases on the way up: watch that log until it settles, then
re-run step 7's checks before calling the update done. Releases land often, sometimes several
times a week: pick a cadence rather than chasing every tag.

## 10. What will probably go wrong

The first boot log. I tailed the container, read a block of capital letters saying `did not
exist; generated fresh secrets for` and a warning about data being lost on restart, and took
it all down assuming the volume was wrong. It was not.
The start-up script prints that whenever `/data/.env` is absent, which on a correct install
happens exactly once, a moment before it writes the file. To tell it from the real failure,
restart the container and look again: if it reappears, `/data` is not persisting and step 2 is
where to look. If it does not, that line is history.

## 11. Out of scope

- Do not set `CUSTOM_DOMAIN`. It makes the container run certbot for its own certificate on
  port 443, which fights the Caddy that already holds the hostname.
- Do not configure SMTP. Budibase runs without it; what it costs is invitation and
  password-reset email, a trade the user makes later, not a step here.
- Do not point `COUCH_DB_URL`, `REDIS_URL` or `DATABASE_URL` outside the container. The embedded
  engines are the shape of this install and moving them is a migration.
- Do not remove the `/db/` rule from the Caddy block, and do not enter a Budibase licence key.
  This installs the community edition on its free self-hosted licence.
````

## 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 Budibase 3.41.3 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, replace `<DOMAIN>` with the hostname whose A record already points at the
box, and replace `<ADMIN_EMAIL>` with the address you intend to sign in as.

Read this before step 1. Budibase's all-in-one image is a crowded container: CouchDB, the
Clouseau search indexer, a Structured Query Server, Redis, MinIO, an internal PostgreSQL and a
LiteLLM proxy all run alongside the Budibase server and worker, under pm2, behind an nginx
inside the container. Upstream asks for 2 cores and 6 GB of RAM. A 2 GB droplet will not run it,
and finding that out at step 7 costs you an hour.

`<ADMIN_EMAIL>` matters more here than in most installs. Step 3 puts it and a generated password
into a file, and the server creates that administrator account during its very first boot, so
there is never a moment when the `Create an admin user` screen is sitting open for whoever finds
your hostname first. Choose the address now, in lowercase, and use exactly those characters when
you sign in.

## 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 `6144` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. A RAM number under 6144
is the one to take seriously rather than push through. The pull alone is over a gigabyte, and
when the memory runs out the OOM killer arrives partway through the first boot, which reads as a
random failure rather than as a decision you made at checkout.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/budibase /srv/budibase/backups
sudo install -d -m 755 /srv/budibase/data
ls -la /srv/budibase
```

You should see: `backups` owned by you, and `data` owned by root at mode `755`.

If you do not: leave `data` owned by root and leave the mode at 755 on purpose. The container
starts as root and then chowns `data/couch` to its CouchDB uid and `data/litellm` to its
PostgreSQL uid, and those processes have to be able to traverse the parent directory. A tighter
mode, or a `chown` to yourself, stops the database from starting with an error that does not
mention permissions. Everything the instance keeps lands in that one directory, including a
`.env` of generated secrets the container writes for itself on the first boot.

## 3. Secrets

Four secrets, all generated here, on the server, and all going straight into a file only you can
read. Three of the four close a door rather than open one, which is worth understanding before
you paste.

```bash
umask 077
cat > /srv/budibase/.env <<EOF
BB_ADMIN_USER_EMAIL=<ADMIN_EMAIL>
PLATFORM_URL=https://<DOMAIN>
BB_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
COUCHDB_PASSWORD=$(openssl rand -hex 32)
INTERNAL_API_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/budibase/.env
umask 022
ls -l /srv/budibase/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` with your real values before you paste. `BB_ADMIN_USER_PASSWORD` is your initial
administrator password, and the server creates the account from it at start-up.
`COUCHDB_PASSWORD` replaces a credential the base image bakes in as the literal word `admin`,
which the container's start-up script leaves alone precisely because it is not empty.
`INTERNAL_API_KEY` rides in the `x-budibase-api-key` header the server and worker call each
other with. `JWT_SECRET` signs every session cookie and, because `API_ENCRYPTION_KEY` is not set
on this shape, it is also the key the platform encrypts stored API keys with. Read your password
once with `sudo grep BB_ADMIN_USER_PASSWORD /srv/budibase/.env` and put it in your password
manager tonight.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. The agent path never sees them; this path hands them to a third party unless
you make a point of not doing it.

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/budibase/.env` and
carry on. Do not delete this file later, either. `docker compose` will not start without it, and
step 8 archives it, because data restored beside a fresh `JWT_SECRET` signs every session out
and cannot decrypt the API keys the old one encrypted.

## 4. compose.yml

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

```bash
cat > /srv/budibase/compose.yml <<'EOF'
# Budibase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://docs.budibase.com/docs/docker
#   start-up script .. https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#
# One service, and it is a crowded one. Upstream's all-in-one image runs
# CouchDB, the Clouseau search indexer, a Structured Query Server, Redis,
# MinIO, an internal PostgreSQL and a LiteLLM proxy alongside the Budibase
# server and worker, all under pm2 behind an nginx inside the container. That
# is why no database service appears below, why the RAM floor is 6 GB, and
# why everything the instance keeps, including the .env of generated secrets
# it writes on first boot, lives under the one /data mount.
#
# CUSTOM_DOMAIN is deliberately never set: it makes the container run certbot
# for a certificate of its own, and the host's Caddy already terminates TLS.
# The container's 443 is never published, only its plain-http 80. The image
# ships its own HEALTHCHECK, so none is declared here. Tag and digest read
# from the registry on 2026-08-12; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  budibase:
    image: budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1
    container_name: budibase
    restart: unless-stopped
    env_file: /srv/budibase/.env
    environment:
      # Upstream ships product analytics on for self-hosted instances. The
      # string "0" is the off switch: backend-core coerces "0" and "false"
      # to a disabled value before anything reads it.
      ENABLE_ANALYTICS: "0"
    volumes:
      - /srv/budibase/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8187.
      - "127.0.0.1:8187:80"
EOF
cd /srv/budibase && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/budibase/.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/budibase/compose.yml` and paste again in one go. Nothing here publishes 443,
because the container only requests a certificate when `CUSTOM_DOMAIN` is set, and setting it
would put a second certificate authority client on a box where Caddy already owns the hostname.

## 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-budibase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Budibase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.budibase.com/docs/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 PLATFORM_URL in .env, the address
# Budibase builds app and invitation links against, so the two have to agree.

<DOMAIN> {
	# The nginx inside the container proxies /db/ straight into the CouchDB
	# that holds every table, row and app here. Upstream documents that path
	# as an operator's route to Fauxton, CouchDB's own admin client:
	# https://docs.budibase.com/docs/accessing-couchdb . That is a tool for
	# whoever runs this box, not a page for the internet, so this refuses it.
	@couchdb path /db/*
	respond @couchdb 403

	# HSTS is the one the container cannot send for itself, because nothing
	# inside it knows it is served over https. No `encode`: the inner nginx
	# already gzips. No X-Frame-Options either, because the application sets
	# frame-ancestors itself from the workspace embed allowlist.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8187 is the loopback port compose publishes on this host. It is not
	# open in the firewall. The builder holds a WebSocket open to /socket/,
	# and reverse_proxy carries that upgrade with no extra configuration.
	reverse_proxy 127.0.0.1:8187
}
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-budibase /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the
other, which leaves a site block Caddy will happily try to get a certificate for. The `/db/`
rule is worth knowing about: the container's own nginx will proxy that path straight into
CouchDB, and this block refuses it before it gets there.

## 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 `8187`, `5984`, `6379`, `9000` or `5432`.

If you do not: delete anything for `8187` with `sudo ufw delete allow 8187`. CouchDB, Redis,
MinIO, PostgreSQL and LiteLLM all live inside the container and listen on the container's own
loopback, so they have no host port a firewall rule could apply to at all. 80/tcp redirects to
HTTPS and answers 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.

## 7. Start and verify

The first boot is slow and it is meant to be. The pull is over a gigabyte, then CouchDB creates
its system databases, PostgreSQL runs `initdb`, LiteLLM applies its migrations, and the server
and worker start last. The loop below waits fifteen minutes.

```bash
cd /srv/budibase
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/system/status
curl -sS https://<DOMAIN>/api/global/configs/checklist | grep -o '"adminUser":{"checked":[a-z]*'
docker compose exec -T budibase curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin http://127.0.0.1:5984/_all_dbs
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/db/_all_dbs
docker compose exec -T budibase sh -c 'chmod 600 /data/.env && stat -c %a /data/.env'
```

You should see, in order: the loop climbing through `502` and reaching `200`, a JSON object
containing `"version":"3.41.3"`, then `"adminUser":{"checked":true`, then `401`, then `403`,
then `600`.

If you do not: `"adminUser":{"checked":true` is the one worth understanding. It means the
administrator account existed before the port ever answered a stranger, so nobody could have
walked into the setup form while you were reading this. If it comes back `false`, stop, do not
open the site, and do not create an account by hand: something kept the two `BB_ADMIN_USER_`
lines from reaching the container. Start over while there is nothing to lose, with
`docker compose down`, `sudo rm -rf /srv/budibase/data`,
`sudo install -d -m 755 /srv/budibase/data`, a check that `.env` really carries both lines, and
`docker compose up -d`. The `401` is the second security answer: it means the CouchDB credential
the base image bakes in no longer works. The `403` is the third: Caddy is refusing the path that
would otherwise reach that database from the internet. A `"version"` that is not `3.41.3` means
you are running a different build than the one this page describes. A loop that never leaves
`502` after fifteen minutes is real: run `docker compose logs --tail 80 budibase` and look for
the container restarting, which is almost always memory.

The first screen at https://<DOMAIN> is the sign-in form, headed `Log in to Budibase`. It is not
the `Create an admin user` screen, and that difference is the whole point of step 3.

Open it now, sign in with `<ADMIN_EMAIL>` and the password you read out of `.env`, and change
that password in your account settings while you are there. There is no mail server on this
install, so there is no reset link and no invitation email: the password you set now is the only
way back in, and it belongs in your password manager before you close the tab.

## 8. First backup and restore

One archive, taken with the container stopped. Several storage engines are writing inside that
directory and a tar of a live CouchDB is not a backup, it is a file that resembles one.

```bash
cd /srv/budibase
docker compose stop
sudo tar -czf /srv/budibase/backups/budibase-$(date +%F).tar.gz -C /srv/budibase compose.yml .env data -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/budibase/backups/
```

You should see: one file, a few hundred megabytes on a fresh install, because several empty
storage engines are still several storage engines. The stop and start cost a few minutes of
downtime while the container brings all of them back up.

If you do not: a `tar: Removing leading /` warning is normal and not an error. `Permission
denied` means you dropped the `sudo`, which you need because `data` belongs to root. Upstream
sells in-product workspace backups as a licensed feature, so this archive is the backup on a
community install rather than a convenience on top of one.

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/budibase
scp vps:/srv/budibase/backups/*.tar.gz ~/backups/budibase/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/budibase/`.

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 `vps` alias Prompt Zero created lives.

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

```bash
cd /srv/budibase
docker compose down
sudo rm -rf /srv/budibase/data
sudo tar -xzf /srv/budibase/backups/budibase-$(date +%F).tar.gz -C /srv/budibase
docker compose up -d
sleep 600
curl -sS https://<DOMAIN>/api/system/status
```

You should see: `"version":"3.41.3"` again, and your account still able to sign in.

If you do not: untar with `sudo`, always. The archive carries the uids CouchDB and PostgreSQL
own their directories as, and an extract that flattens those owners gives you a container that
starts and databases that do not. `.env` is inside that archive on purpose, so it is back in
place before the first start: compose will not start without it, and a data directory restored
beside a fresh `JWT_SECRET` signs every session out and cannot decrypt the API keys the old one
encrypted. Ten minutes is the shortest wait worth giving it.

## 9. Updating later

New versions are listed at https://github.com/Budibase/budibase/releases. 3.41.3 was the newest
stable release on the day this was pinned. Take the backup above first, then edit the `image:`
line in /srv/budibase/compose.yml to the new tag and its digest.

```bash
cd /srv/budibase
docker compose pull
docker compose up -d
docker compose logs --tail 60 budibase
```

You should see: migration output, then the server and worker 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
whole check block from step 7 before you call the update done, and open one of your own apps as
well, because a server that answers with a version string can still be failing on a migration
that stopped halfway. Budibase releases often, sometimes several times a week, so pick a cadence
rather than chasing every tag.

## 10. What will probably go wrong

The first boot log. I tailed the container, read a block of capital letters saying `did not
exist; generated fresh secrets for` followed by a list of variable names and a warning about
data being lost on restart, and took it all down assuming the volume was wrong. It was not. The
start-up script prints that whenever `/data/.env` is absent, which on a correct install happens
exactly once, a moment before it writes the file. To tell it from the real failure, restart the
container and look again: if it reappears, `/data` is genuinely not persisting and step 2 is
where to look. If it does not, that log line is history.

## 11. Out of scope

- Do not set `CUSTOM_DOMAIN`. It makes the container run certbot for its own certificate on
  port 443, which fights the Caddy that already holds the hostname.
- Do not configure SMTP. Budibase runs without it; what it costs is invitation and
  password-reset email, a trade you make later rather than a step here.
- Do not point `COUCH_DB_URL`, `REDIS_URL` or `DATABASE_URL` outside the container. The embedded
  engines are the shape of this install and moving them is a migration.
- Do not delete /srv/budibase/.env and do not enter a Budibase licence key. This installs the
  community edition on its free self-hosted licence.
````

## 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 Budibase 3.41.3 under ~/selfhost/budibase, answering at http://localhost:8187.

## 1. Preflight

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

Detect the OS and measure the machine:

```bash
uname -s
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. Budibase needs 6144 MB of RAM available and
20 GB free on the home disk; the image publishes amd64 and arm64. The floor is upstream's own
figure, and one container runs CouchDB, the Clouseau search indexer, a Structured Query Server,
Redis, MinIO, an internal PostgreSQL and a LiteLLM proxy alongside the server and worker. On macOS and
Windows the number printed is the host's and Docker Desktop takes its allocation out of that, so
a 16 GB laptop capped at 4 GB will not run this. Under 6144 MB or 20 GB free, print both and
stop.

## 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/budibase/backups
ls -la ~/selfhost/budibase
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder on purpose: step
5 keeps the instance's directory in a named Docker volume, because the start-up script chowns
CouchDB's and PostgreSQL's directories to uids Windows file sharing cannot grant.

## 4. Secrets

Four secrets, generated here. Print none of them, and keep them out of your summary and your
logs. Hex rather than base64: a human types one into a login form.

STOP: ask the user which email address they want to sign in with, and wait.
Do not continue until they answer. Take it in lowercase. That address becomes the administrator,
created from this file during the first boot, so the setup form is never open to anyone.

Write the file below with that address in place of `you@example.com`:

```bash
umask 077
cat > ~/selfhost/budibase/.env <<EOF
BB_ADMIN_USER_EMAIL=you@example.com
PLATFORM_URL=http://localhost:8187
BB_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
COUCHDB_PASSWORD=$(openssl rand -hex 32)
INTERNAL_API_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/budibase/.env
umask 022
ls -l ~/selfhost/budibase/.env
```

Assert: the file exists with mode `-rw-------`, and the first line carries the address the user
gave rather than the example one. Git Bash ships openssl, so these lines run the same on all
three systems. `COUCHDB_PASSWORD` matters most: the base image bakes that credential in as the
literal word `admin`, and the start-up script leaves it alone because it is not empty.
`JWT_SECRET` signs session cookies and, with `API_ENCRYPTION_KEY` unset on this shape, is also
the key the platform encrypts stored API keys with. Tell the user to read their password with
`grep BB_ADMIN_USER_PASSWORD ~/selfhost/budibase/.env`, keep it in their password manager, and
keep the file: compose will not start without it, and step 8 copies it off this machine.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/budibase/compose.yml <<'EOF'
# Budibase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ... https://docs.budibase.com/docs/docker
#   start-up script .. https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#
# One crowded service: upstream's all-in-one image runs CouchDB, Clouseau, a
# Structured Query Server, Redis, MinIO, an internal PostgreSQL and a LiteLLM
# proxy alongside the Budibase server and worker, which is where the 6 GB
# floor comes from. /data is a named volume, not a relative bind mount: the
# start-up script chowns its CouchDB and PostgreSQL directories to uids of its
# own choosing, and Windows file sharing cannot grant that on a home folder.
# ./backups stays a real folder. Digest read on 2026-08-12; amd64, arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  budibase:
    image: budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1
    container_name: budibase
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Upstream ships product analytics on for self-hosted instances. The
      # string "0" is the off switch: backend-core coerces "0" and "false"
      # to a disabled value before anything reads it.
      ENABLE_ANALYTICS: "0"
    volumes:
      - budibase-data:/data
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8187.
      - "127.0.0.1:8187:80"

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

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS, because there is no hostname to resolve.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the builder still works.
- No firewall rule. Nothing is published beyond loopback, so nothing needs closing.

8187 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop
on the same wifi, nor anyone on the internet. For a tool whose apps are meant for other people,
that is the trade here. Confirm:

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

Assert: that prints `1`. CouchDB, Redis, MinIO, PostgreSQL and LiteLLM are never published at
all, and step 4 replaced the CouchDB credential rather than trust the binding alone.

## 7. Start and verify

The first boot is slow and meant to be: over a gigabyte to pull, then CouchDB's system
databases, PostgreSQL's `initdb` and LiteLLM's migrations before the server and worker start.
Fifteen minutes on a laptop sharing its cores is normal.

```bash
cd ~/selfhost/budibase
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:8187/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8187/api/system/status
curl -sS http://localhost:8187/api/global/configs/checklist | grep -o '"adminUser":{"checked":[a-z]*'
docker compose exec -T budibase curl -sS -o /dev/null -w '%{http_code}\n' -u admin:admin http://127.0.0.1:5984/_all_dbs
docker compose exec -T budibase sh -c 'chmod 600 /data/.env && stat -c %a /data/.env'
```

Assert, all five, and print what you received for each. The loop ends printing `200`. The status
response contains `"version":"3.41.3"`, the running build agreeing with the pinned tag. The
checklist prints `"adminUser":{"checked":true`, so the administrator came from step 4, not from
whoever opened the page first. The fourth prints `401`, so the CouchDB credential the base
image bakes in is dead. The last prints `600`: the container writes that file with its own
umask, and CouchDB and PostgreSQL run in there as uids of their own.

If any of the five misses, stop, run `docker compose logs --tail 80 budibase`, and name the
likely cause: a container restarting in a loop is usually Docker Desktop's memory cap, and `port
is already allocated` means something else holds 8187 (`lsof -nP -iTCP:8187 -sTCP:LISTEN`, or
`netstat -ano | findstr :8187` on Windows). On `"adminUser":{"checked":false`, do not create an
account by hand. Reset: `docker compose down -v`, check step 4's `.env` still carries both
`BB_ADMIN_USER_` lines, then `docker compose up -d` and repeat. A running container is not
success.

The first screen is the sign-in form, headed `Log in to Budibase`, not the `Create an admin
user` screen. That difference is what step 4 buys.

STOP: tell the user to read their password with
`grep BB_ADMIN_USER_PASSWORD ~/selfhost/budibase/.env`, sign in at http://localhost:8187 with
the address they gave, change the password in their account settings, and wait.
Do not continue until they confirm they are signed in. There is no mail here, so no reset
link.

## 8. First backup and restore

One archive, with the container stopped, because a tar of a live CouchDB is not a backup. It
runs inside a throwaway container so the uids CouchDB and PostgreSQL own their files as
survive:

```bash
cd ~/selfhost/budibase
docker compose stop
docker run --rm --volumes-from budibase --entrypoint sh budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1 -c "tar -czf /backup/budibase-$(date +%F).tar.gz -C / data"
docker compose start
ls -lh ~/selfhost/budibase/backups/
```

Assert: the archive exists and is non-empty. Print its size. The stop and start cost several
minutes while the container brings every engine back up. Upstream sells in-product workspace
backups as a licensed feature, so this file is it.

That archive sits on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination off this computer, a folder their sync service watches
or a USB stick, and copy it there with `cp`, together with `~/selfhost/budibase/.env`: data
restored beside a fresh `JWT_SECRET` cannot decrypt what the old one encrypted. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both files
are there. If they have neither, say plainly that this install has no backup.

To restore, in order. `cd ~/selfhost/budibase`, put `.env` and `compose.yml` back if they
are missing, then `docker compose down -v`, which drops the old volume on purpose, then
`docker compose create`, which makes an empty one. Then the same `docker run` line with
`tar -xzf` and the archive's filename in place of `tar -czf` and the date, still with `-C /`.
Then `docker compose up -d` and re-run step 7. That is the plan.

## 9. Updating later

New versions are listed at https://github.com/Budibase/budibase/releases. 3.41.3 was the newest
stable release the day this was pinned. Take step 8's backup first, then edit the image line in
~/selfhost/budibase/compose.yml:

```bash
cd ~/selfhost/budibase
docker compose pull
docker compose up -d
docker compose logs --tail 60 budibase
```

Budibase migrates its own databases on the way up: watch that log until it settles, then re-run
step 7 before calling the update done.

## 10. What will probably go wrong

Docker Desktop's memory cap. I gave this a laptop with 16 GB and watched the container restart
in a loop for twenty minutes, hunting a mistake that was not there. Docker Desktop was handing
its virtual machine 4 GB, and the pile of processes in this image does not fit in 4 GB. The
number step 1 printed was the laptop's, not Docker's. Open Docker Desktop, Settings, Resources,
give it 6 GB or more, apply, restart, then run step 7 again.

## 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 8187 to 0.0.0.0 so a colleague on the wifi can open an app. That publishes the
  builder, its CouchDB at `/db/`, and every datasource credential it holds, onto every network
  this machine joins.
- Do not configure SMTP, and do not set `CUSTOM_DOMAIN`: it runs certbot for a name that does
  not exist here.
````

## docker-compose.yml

```yaml
# Budibase · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://docs.budibase.com/docs/docker
#   start-up script .. https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#
# One service, and it is a crowded one. Upstream's all-in-one image runs
# CouchDB, the Clouseau search indexer, a Structured Query Server, Redis,
# MinIO, an internal PostgreSQL and a LiteLLM proxy alongside the Budibase
# server and worker, all under pm2 behind an nginx inside the container. That
# is why no database service appears below, why the RAM floor is 6 GB, and
# why everything the instance keeps, including the .env of generated secrets
# it writes on first boot, lives under the one /data mount.
#
# CUSTOM_DOMAIN is deliberately never set: it makes the container run certbot
# for a certificate of its own, and the host's Caddy already terminates TLS.
# The container's 443 is never published, only its plain-http 80. The image
# ships its own HEALTHCHECK, so none is declared here. Tag and digest read
# from the registry on 2026-08-12; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  budibase:
    image: budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1
    container_name: budibase
    restart: unless-stopped
    env_file: /srv/budibase/.env
    environment:
      # Upstream ships product analytics on for self-hosted instances. The
      # string "0" is the off switch: backend-core coerces "0" and "false"
      # to a disabled value before anything reads it.
      ENABLE_ANALYTICS: "0"
    volumes:
      - /srv/budibase/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8187.
      - "127.0.0.1:8187:80"
```

## compose.local.yml

```yaml
# Budibase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ... https://docs.budibase.com/docs/docker
#   start-up script .. https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#
# One crowded service: upstream's all-in-one image runs CouchDB, Clouseau, a
# Structured Query Server, Redis, MinIO, an internal PostgreSQL and a LiteLLM
# proxy alongside the Budibase server and worker, which is where the 6 GB
# floor comes from. /data is a named volume, not a relative bind mount: the
# start-up script chowns its CouchDB and PostgreSQL directories to uids of its
# own choosing, and Windows file sharing cannot grant that on a home folder.
# ./backups stays a real folder. Digest read on 2026-08-12; amd64, arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  budibase:
    image: budibase/budibase:3.41.3@sha256:f05b90c2b8afc951feb99931bb4646d2c94af37d9c576ef3c4e01d4fdc296dc1
    container_name: budibase
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Upstream ships product analytics on for self-hosted instances. The
      # string "0" is the off switch: backend-core coerces "0" and "false"
      # to a disabled value before anything reads it.
      ENABLE_ANALYTICS: "0"
    volumes:
      - budibase-data:/data
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8187.
      - "127.0.0.1:8187:80"

volumes:
  budibase-data:
```

## Caddyfile

```text
# Budibase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.budibase.com/docs/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 PLATFORM_URL in .env, the address
# Budibase builds app and invitation links against, so the two have to agree.

<DOMAIN> {
	# The nginx inside the container proxies /db/ straight into the CouchDB
	# that holds every table, row and app here. Upstream documents that path
	# as an operator's route to Fauxton, CouchDB's own admin client:
	# https://docs.budibase.com/docs/accessing-couchdb . That is a tool for
	# whoever runs this box, not a page for the internet, so this refuses it.
	@couchdb path /db/*
	respond @couchdb 403

	# HSTS is the one the container cannot send for itself, because nothing
	# inside it knows it is served over https. No `encode`: the inner nginx
	# already gzips. No X-Frame-Options either, because the application sets
	# frame-ancestors itself from the workspace embed allowlist.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8187 is the loopback port compose publishes on this host. It is not
	# open in the firewall. The builder holds a WebSocket open to /socket/,
	# and reverse_proxy carries that upgrade with no extra configuration.
	reverse_proxy 127.0.0.1:8187
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Budibase · 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=apps.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.budibase.com/docs/docker
#   https://github.com/Budibase/budibase/blob/3.41.3/hosting/single/runner.sh
#   https://github.com/Budibase/budibase/blob/3.41.3/hosting/hosting.properties
#
# Four secrets are generated here, on this machine, into /srv/budibase/.env
# with mode 600, and none of them is ever printed. Three of them close a door:
# the administrator password creates the account during the first boot so no
# stranger can claim the setup form, the CouchDB password replaces a credential
# the base image bakes in as the literal word admin, and the internal API key
# is the credential the server and worker call each other with. The fourth,
# JWT_SECRET, signs session cookies and, with API_ENCRYPTION_KEY unset on this
# shape, is also the key the platform encrypts stored API keys with.
#
# ADMIN_EMAIL is the address that account is created for. Sign in with it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/budibase}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

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. apps.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address that will own this instance, in lowercase"
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"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 6144 ] || die "only ${avail_mb} MB of RAM available; the all-in-one image wants 6144 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 ----------------------------------------------------
#
# data stays owned by root at mode 755: the container starts as root and then
# chowns data/couch to its CouchDB uid and data/litellm to its PostgreSQL uid,
# and both of those processes have to traverse the parent.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 "$APP_DIR/data"
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 rather than base64: one of them is typed into a login form by a human and
# the rest ride through a shell that word-splits its own env file. Read the
# administrator password later with
#   sudo grep BB_ADMIN_USER_PASSWORD /srv/budibase/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BB_ADMIN_USER_EMAIL=${ADMIN_EMAIL}
		PLATFORM_URL=https://${DOMAIN_HOST}
		BB_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
		COUCHDB_PASSWORD=$(openssl rand -hex 32)
		INTERNAL_API_KEY=$(openssl rand -hex 32)
		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-budibase"
	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 8187 is not one of them --------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8187 stays 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 over a gigabyte, creates CouchDB's system databases,
# runs PostgreSQL's initdb and LiteLLM's migrations, and only then starts the
# server and worker. This waits fifteen minutes.

docker compose pull
docker compose up -d

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

# Image identity: the build that is running has to be the tag that was pinned.
status="$(curl -sS "https://${DOMAIN_HOST}/api/system/status")"
printf '%s\n' "$status"
printf '%s' "$status" | grep -q '"version":"3.41.3"' \
	|| die "the running build is not 3.41.3. Received: ${status}"

# The administrator must already exist, before the port ever answered a
# stranger. A false here means the BB_ADMIN_USER_ lines never reached the
# container: destroy data/ and start over while nothing is at stake.
checklist="$(curl -sS "https://${DOMAIN_HOST}/api/global/configs/checklist" | grep -o '"adminUser":{"checked":[a-z]*' || true)"
printf '%s\n' "$checklist"
[ "$checklist" = '"adminUser":{"checked":true' ] \
	|| die "no administrator was created. Received: ${checklist:-nothing}. Stop, run: docker compose down && sudo rm -rf ${APP_DIR}/data, then rerun this script."

# The credential the base image bakes in has to be dead.
couch="$(docker compose exec -T budibase curl -sS -o /dev/null -w '%{http_code}' -u admin:admin http://127.0.0.1:5984/_all_dbs)"
printf 'couchdb baked-in credential: %s\n' "$couch"
[ "$couch" = "401" ] || die "the baked-in CouchDB credential still works (received ${couch}). Check COUCHDB_PASSWORD in ${APP_DIR}/.env"

# Caddy has to refuse the path the container's own nginx proxies into CouchDB.
dbpath="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/db/_all_dbs")"
printf '/db/ from the internet: %s\n' "$dbpath"
[ "$dbpath" = "403" ] || die "/db/ answered ${dbpath} instead of 403. Check the site block in /etc/caddy/Caddyfile"

# The container writes its own generated secrets into /data/.env world-readable.
envmode="$(docker compose exec -T budibase sh -c 'chmod 600 /data/.env && stat -c %a /data/.env')"
printf 'container /data/.env mode: %s\n' "$envmode"
[ "$envmode" = "600" ] || die "the container's /data/.env is mode ${envmode}, not 600"

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, because several storage engines are writing in there and a tar of a
# live CouchDB is a file that resembles a backup.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/budibase-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env data -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/budibase-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Budibase 3.41.3 is answering at https://${DOMAIN_HOST}

	  1. Sign in at https://${DOMAIN_HOST} as
	       ${ADMIN_EMAIL}
	     The first screen is headed "Log in to Budibase", not "Create an
	     admin user": the account was created during the first boot, so
	     nobody else could claim this instance.
	  2. Your initial password is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep BB_ADMIN_USER_PASSWORD $APP_DIR/.env
	     then change it in your account settings. It was not printed here.
	     There is no mail server on this install, so there is no reset link.
	  3. Keep $APP_DIR/.env. Compose will not start without it, and data
	     restored beside a fresh JWT_SECRET signs every session out and
	     cannot decrypt the API keys the old one encrypted: with
	     API_ENCRYPTION_KEY unset on this shape, JWT_SECRET is that key.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/budibase/
	     Restore is: docker compose down, sudo rm -rf $APP_DIR/data,
	     sudo tar -xzf the archive -C $APP_DIR, docker compose up -d.
	     Untar with sudo, always: the archive carries the uids CouchDB and
	     PostgreSQL own their directories as.

DONE
```

## Also evaluated

Ranked below Budibase for this swap. The prompts above install Budibase 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 better answer if you already have the database. Appsmith is a drag-and-drop editor over your own PostgreSQL, MySQL, MongoDB, REST APIs and forty other datasources, with JavaScript wherever a value goes, and its community edition is Apache-2.0 all the way through, which is a cleaner licence than anything else on this page. It is ranked second here only because the Quickbase shape is data-first: people arriving from Quickbase are usually carrying tables, not a database server, and Appsmith has no tables of its own to put them in. It also asks for 8 GB rather than 6, for the same reason Budibase asks for 6, and its own paid list covers SSO, audit logs, granular roles and workflows. Appsmith holds the top spot on the Retool page, where the premise is a UI over a database you already operate, and that is the honest division between the two.

The page this mirrors: https://caniselfhostit.com/self-host/quickbase/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
