# Can I self-host Baserow?

**YES** — it's called Baserow. ONE EVENING setup · ~1.5 hours to running · 4 GB RAM minimum · $60/mo you stop paying ($720/yr on the Premium plan, 5 seats assumed).

Baserow authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/baserow-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 Baserow 2.3.3 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. That hostname becomes `BASEROW_PUBLIC_URL`, and
the Caddy inside the container matches the Host header against it to decide whether a request is
Baserow's, so changing it later takes the API routes down.

Baserow needs 4096 MB of RAM available and 10 GB free on /srv. That floor is not padding: the
image upstream calls all-in-one runs a PostgreSQL 15 and a Redis inside the same container as
the Django backend, the Nuxt web frontend and a Caddy of its own, and upstream's own capacity
guidance for one of these containers is 2 vCPU and 4 GB even when the database sits outside it.
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 4096 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope: the OOM killer arrives partway through the first migration and the failure
looks random. If `dig +short` prints nothing, print that and stop.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by root. Leave `data`
to root. The container starts as root and chowns that directory to uid 9999 before it drops
privileges, and the PostgreSQL inside it refuses to initialise in a directory somebody else
already owns. Everything the instance keeps lands under there: the database cluster, the Redis
dump, the uploaded files and the plugins.

## 3. Secrets

Two secrets: the Django secret key and the JWT signing key. The image generates a pair of its
own into /baserow/data if none arrive from outside, and values set from outside win, so generate
them here where they land in a file the backup carries. Do not print either, do not repeat them
in your summary, and do not put them in any log line.

```bash
umask 077
cat > /srv/baserow/.env <<EOF
BASEROW_PUBLIC_URL=https://<DOMAIN>
SECRET_KEY=$(openssl rand -hex 32)
BASEROW_JWT_SIGNING_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/baserow/.env
umask 022
ls -l /srv/baserow/.env
```

Assert: the file exists with mode `-rw-------`. Hex rather than base64, because both values are
read by a shell before Django ever sees them and neither wants escaping. Tell the user to read
the pair back with `sudo grep -E 'SECRET_KEY|SIGNING_KEY' /srv/baserow/.env` and put both in
their password manager tonight. The first signs session cookies, the second signs every API
token, and a restore without them logs everybody out of an instance that no longer recognises
its own tokens.

## 4. compose.yml

```bash
cat > /srv/baserow/compose.yml <<'EOF'
# Baserow · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://baserow.io/docs/installation%2Finstall-with-docker
#   variable reference . https://baserow.io/docs/installation%2Fconfiguration
#   capacity guidance .. https://baserow.io/docs/installation%2Finstall-on-aws
#   supported versions . https://baserow.io/docs/installation%2Fsupported
#
# One service, and it is five processes wearing one hat. Upstream's all-in-one
# image runs a PostgreSQL 15 and a Redis inside this same container next to the
# Django backend, the Nuxt web frontend and a Caddy of its own, and keeps every
# byte of that under /baserow/data. That is why no database service appears
# below, and why the RAM floor is 4 GB rather than the few hundred megabytes a
# web application on its own would want.
#
# BASEROW_CADDY_ADDRESSES is pinned to :80 so the container's Caddy serves plain
# http and never asks Let's Encrypt for anything. The host Caddy already holds
# the hostname and terminates TLS, so only the container's port 80 is published.
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The image carries its own HEALTHCHECK, so this file adds
# none.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  baserow:
    image: baserow/baserow:2.3.3@sha256:41adb3493379403946a493f30873f743bb65b19b5f387d630ec75f41e25d5b5b
    container_name: baserow
    restart: unless-stopped
    env_file: /srv/baserow/.env
    environment:
      # :80 keeps the inner Caddy on plain http and off the ACME path.
      BASEROW_CADDY_ADDRESSES: ":80"
      # The host Caddy terminates TLS and sets X-Forwarded-Proto itself, which
      # is the condition upstream names for turning this on. Without it the
      # paginated API hands out http:// links for an https-only service.
      BASEROW_ENABLE_SECURE_PROXY_SSL_HEADER: "yes"
      # One celery worker running both the fast and the slow queue. Upstream
      # names this pair as the way to lower the image's memory use, and the
      # price is that a large export can delay a realtime row update.
      BASEROW_AMOUNT_OF_WORKERS: "1"
      BASEROW_RUN_MINIMAL: "yes"
    volumes:
      - /srv/baserow/data:/baserow/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8175.
      - "127.0.0.1:8175:80"
EOF
cd /srv/baserow && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind 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 here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-baserow
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Baserow · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://baserow.io/docs/installation%2Finstall-with-docker,
# https://baserow.io/docs/installation%2Fconfiguration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASEROW_PUBLIC_URL in .env. The container's own Caddy decides whether a
# request is for Baserow by comparing the Host header against that value, so
# the two have to stay the same string or the API routes stop answering.

<DOMAIN> {
	# The web frontend is a large JavaScript bundle and the API answers JSON.
	# The container's Caddy compresses neither, so this is the only place it
	# happens. Uploaded files are served from the same hostname and Caddy
	# leaves already-compressed images alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Realtime collaboration holds a websocket open on /ws/, and reverse_proxy
	# carries that upgrade with no extra directive. Caddy also sets
	# X-Forwarded-Proto here, which is what lets the container be told it is
	# behind https.
	#
	# 8175 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8175
}
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-baserow, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own, so there is nothing to schedule.

## 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 redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8175 stays closed because compose binds it to 127.0.0.1. The PostgreSQL and
the Redis live inside the container and are never published at all, so there is no host port for
them to firewall. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8175, 5432 or 6379.

## 7. Start and verify

The first boot is slow. The pull is over a gigabyte, then PostgreSQL initialises a cluster,
Django runs every migration from scratch and the built-in templates import in the background.
Upstream's deployment guide asks for a 900-second grace period on a first start, and this loop
allows exactly that.

```bash
cd /srv/baserow
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>/api/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/_health/
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"show_admin_signup_page":[a-z]*'
```

Assert, all three, and print what you received for each. The loop ends printing `200`. The
health endpoint answers with the two characters `OK` and nothing else. The third command prints
`"show_admin_signup_page":true`, which means no account exists yet and the next person to reach
this hostname becomes the administrator. If any of the three misses, stop, run
`docker compose logs --tail 60 baserow`, and name the likely cause: a `502` inside the first
fifteen minutes is the migrations still running, a `502` past fifteen minutes points at step 4, a
certificate error points at step 5, and a container restarting in a loop usually means the RAM
floor in step 1 was measured on a box that had already given the memory away.

The first screen at https://<DOMAIN> is the sign-up form. Baserow sends the login page straight
to it while no account exists, and it carries the notice `Welcome to Baserow!` above the line
`Please fill the form below to create the admin user.`

STOP: tell the user to open https://<DOMAIN>, fill that form in, and then, once they are signed
in, open https://<DOMAIN>/admin/settings and turn off `Allow creating new accounts`. Wait. Do
not continue until they confirm both. The first account created on an instance is given staff
rights, which is what makes it the administrator, and until that toggle is off any visitor can
make an account of their own. 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.

```bash
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"show_admin_signup_page":[a-z]*'
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"allow_new_signups":[a-z]*'
```

Assert: the first prints `"show_admin_signup_page":false` and the second prints
`"allow_new_signups":false`. Both must pass before you report success. A running container is
not success, and an instance still offering accounts to strangers is not success either.

## 8. First backup and restore

One archive, taken with the container stopped. A PostgreSQL cluster and a Redis are writing
inside that directory, and a tar of a live database is a file that looks like a backup.

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

Assert: the archive exists and is non-empty. Print its size. On a fresh install with the
templates imported it runs to a few hundred megabytes, and the stop and start cost about a
minute.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/baserow/data`, then
`sudo tar -xzf /srv/baserow/backups/<archive> -C /srv/baserow`, then `docker compose up -d`.
Untar it with sudo, always, because the archive carries the uid 9999 that PostgreSQL owns its
cluster as, and an extract that flattens those owners gives a container that starts and a
database that does not. Tell the user two things about that archive. It holds a raw copy of the
PostgreSQL data directory, so it restores into this same image and this same major version and
not into a PostgreSQL you install somewhere else. And it holds .env, which is the only copy of
the two keys from step 3. Those four commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/baserow/baserow/releases. Take the backup from
step 8 first, then edit the image line in /srv/baserow/compose.yml to the new tag and its digest:

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

Baserow migrates its own database on the way up and prints `Baserow is now available at` when it
has finished. Watch that log until that line appears, then re-run the health check from step 7
before calling the update done.

## 10. What will probably go wrong

The wait. I brought this up on a 4 GB box, watched the browser return `502` for eleven minutes,
decided the reverse proxy was wrong and started taking the Caddy block apart. Nothing was wrong.
A first boot initialises a PostgreSQL cluster, runs every Django migration in order and then
imports the built-in templates, and none of that answers a request. The way to tell waiting from
broken is the loop in step 7: while it prints `502` or `000` under fifteen minutes it is still
coming up, and past fifteen minutes something is actually wrong. Read the log rather than the
browser, with `docker compose logs -f baserow`.

## 11. Out of scope

- Do not set `BASEROW_CADDY_ADDRESSES` to `:443` or to an https URL. That makes the container
  ask Let's Encrypt for its own certificate on a hostname the host Caddy already holds.
- Do not configure SMTP. Baserow runs without it; what it costs is invitation mail and
  password-reset mail, and that is a decision the user makes later, not a step here.
- Do not point `DATABASE_URL` or `REDIS_URL` at anything outside the container. The embedded
  pair is the shape of this install, and moving them is a migration rather than a setting.
- Do not add a licence key or install any premium or enterprise feature. This prompt installs
  the free edition, which is the MIT-licensed part of the repository.
````

## 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 Baserow 2.3.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, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1. `<DOMAIN>` becomes `BASEROW_PUBLIC_URL`, and the Caddy that runs inside
the Baserow container matches the Host header against that value to decide whether a request is
Baserow's. Change the hostname later and the API routes stop answering until you change the
variable too. Pick the name 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 `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: the RAM line is the one to take seriously. The image upstream calls all-in-one
runs a PostgreSQL 15 and a Redis inside the same container as the Django backend, the Nuxt web
frontend and a Caddy of its own, and upstream's own capacity guidance for one of these
containers is 2 vCPU and 4 GB even when the database sits outside it. On a smaller box the OOM
killer arrives partway through the first migration and the failure looks random. 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.

## 2. Layout

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

You should see: `backups` owned by you, and `data` owned by root at mode `drwxr-xr-x`.

If you do not: leave `data` owned by root on purpose. The container starts as root, chowns that
directory to uid 9999 and only then drops privileges, and the PostgreSQL inside it refuses to
initialise in a directory somebody else already owns. Everything the instance keeps lands under
there: the database cluster, the Redis dump, your uploaded files and the plugins.

## 3. Secrets

Two secrets: the Django secret key and the JWT signing key. The image will generate a pair of
its own into /baserow/data if none arrive from outside, and values set from outside win, so
these are generated here and land in a file only you can read, which is also a file the backup
carries.

```bash
umask 077
cat > /srv/baserow/.env <<EOF
BASEROW_PUBLIC_URL=https://<DOMAIN>
SECRET_KEY=$(openssl rand -hex 32)
BASEROW_JWT_SIGNING_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/baserow/.env
umask 022
ls -l /srv/baserow/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste. Read the pair back once with
`sudo grep -E 'SECRET_KEY|SIGNING_KEY' /srv/baserow/.env` and put both in your password manager
tonight: the first signs session cookies, the second signs every API token, and a restore that
arrives without them logs everybody out of an instance that no longer recognises its own tokens.

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/baserow/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten both
keys, which is harmless before the first start and a mass sign-out afterwards.

Do not paste that file, either key, or any command output containing them into this chat window.
The chat path is the one place these values can leave your machine, and nothing in this install
needs you to show them to anybody.

## 4. compose.yml

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

```bash
cat > /srv/baserow/compose.yml <<'EOF'
# Baserow · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://baserow.io/docs/installation%2Finstall-with-docker
#   variable reference . https://baserow.io/docs/installation%2Fconfiguration
#   capacity guidance .. https://baserow.io/docs/installation%2Finstall-on-aws
#   supported versions . https://baserow.io/docs/installation%2Fsupported
#
# One service, and it is five processes wearing one hat. Upstream's all-in-one
# image runs a PostgreSQL 15 and a Redis inside this same container next to the
# Django backend, the Nuxt web frontend and a Caddy of its own, and keeps every
# byte of that under /baserow/data. That is why no database service appears
# below, and why the RAM floor is 4 GB rather than the few hundred megabytes a
# web application on its own would want.
#
# BASEROW_CADDY_ADDRESSES is pinned to :80 so the container's Caddy serves plain
# http and never asks Let's Encrypt for anything. The host Caddy already holds
# the hostname and terminates TLS, so only the container's port 80 is published.
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The image carries its own HEALTHCHECK, so this file adds
# none.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  baserow:
    image: baserow/baserow:2.3.3@sha256:41adb3493379403946a493f30873f743bb65b19b5f387d630ec75f41e25d5b5b
    container_name: baserow
    restart: unless-stopped
    env_file: /srv/baserow/.env
    environment:
      # :80 keeps the inner Caddy on plain http and off the ACME path.
      BASEROW_CADDY_ADDRESSES: ":80"
      # The host Caddy terminates TLS and sets X-Forwarded-Proto itself, which
      # is the condition upstream names for turning this on. Without it the
      # paginated API hands out http:// links for an https-only service.
      BASEROW_ENABLE_SECURE_PROXY_SSL_HEADER: "yes"
      # One celery worker running both the fast and the slow queue. Upstream
      # names this pair as the way to lower the image's memory use, and the
      # price is that a large export can delay a realtime row update.
      BASEROW_AMOUNT_OF_WORKERS: "1"
      BASEROW_RUN_MINIMAL: "yes"
    volumes:
      - /srv/baserow/data:/baserow/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8175.
      - "127.0.0.1:8175:80"
EOF
cd /srv/baserow && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/baserow/.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/baserow/compose.yml` and paste again in one go.

## 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-baserow
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Baserow · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://baserow.io/docs/installation%2Finstall-with-docker,
# https://baserow.io/docs/installation%2Fconfiguration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASEROW_PUBLIC_URL in .env. The container's own Caddy decides whether a
# request is for Baserow by comparing the Host header against that value, so
# the two have to stay the same string or the API routes stop answering.

<DOMAIN> {
	# The web frontend is a large JavaScript bundle and the API answers JSON.
	# The container's Caddy compresses neither, so this is the only place it
	# happens. Uploaded files are served from the same hostname and Caddy
	# leaves already-compressed images alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Realtime collaboration holds a websocket open on /ws/, and reverse_proxy
	# carries that upgrade with no extra directive. Caddy also sets
	# X-Forwarded-Proto here, which is what lets the container be told it is
	# behind https.
	#
	# 8175 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8175
}
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-baserow /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in the site line but not in
the comment above it, which is harmless, or one you replaced nowhere, which is not.

## 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 `8175`, `5432` or `6379`.

If you do not: delete anything for `8175` with `sudo ufw delete allow 8175`. 8175 is bound to
127.0.0.1 by the compose file, and the PostgreSQL and Redis live inside the container and are
never published at all, so neither has a host port a firewall rule could apply to. 80/tcp is
there to redirect to HTTPS and 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 any further.

## 7. Start and verify

The first boot is slow, and this is the step where patience is the skill. The pull is over a
gigabyte, then PostgreSQL initialises a cluster, Django runs every migration from scratch and
the built-in templates import in the background. Upstream's deployment guide asks for a
900-second grace period on a first start, and the loop below allows exactly that.

```bash
cd /srv/baserow
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>/api/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/_health/
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"show_admin_signup_page":[a-z]*'
```

You should see, in order: the loop climbing through `502` or `000` and ending on `200`, then the
two characters `OK` on their own line, then `"show_admin_signup_page":true`.

If you do not: read the log rather than the browser, with `docker compose logs -f baserow`. A
`502` inside the first fifteen minutes is the migrations still running. A `502` past fifteen
minutes points at step 4, a certificate error at step 5, and a container restarting in a loop
usually means the RAM in step 1 was measured on a box that had already given the memory away.
`"show_admin_signup_page":false` on a brand-new install would mean an account already exists,
which on a hostname that has been public for a while means somebody else has claimed it: destroy
the install with `docker compose down` and `sudo rm -rf /srv/baserow/data`, recreate the
directory as in step 2, and start again.

Now open https://<DOMAIN> in a browser. The first screen is the sign-up form: Baserow sends the
login page straight to it while no account exists, and it carries the notice
`Welcome to Baserow!` above the line `Please fill the form below to create the admin user.`
Fill it in. That account is given staff rights, which is what makes it the administrator, and
there is no mail server here, so there is no reset link: put the password in your password
manager as you type it.

Then, still signed in, open https://<DOMAIN>/admin/settings and turn off
`Allow creating new accounts`. Until you do, any visitor to your hostname can make an account.
Confirm both from the server:

```bash
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"show_admin_signup_page":[a-z]*'
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"allow_new_signups":[a-z]*'
```

You should see: `"show_admin_signup_page":false` and `"allow_new_signups":false`.

If you do not: a `true` on the second line means the toggle did not save, so reload
https://<DOMAIN>/admin/settings and check it again. Do not stop here with it open. A running
container is not success, and an instance still offering accounts to strangers is not success
either.

## 8. First backup and restore

One archive, taken with the container stopped. A PostgreSQL cluster and a Redis are writing
inside that directory, and a tar of a live database is a file that looks like a backup.

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

You should see: one file, a few hundred megabytes on a fresh install with the templates
imported. The stop and start cost about a minute.

If you do not: an archive of a few kilobytes means the `data` argument matched nothing, so check
you are in /srv/baserow and that step 2 created the directory. `tar: Removing leading /` is a
notice, not an error.

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

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

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 database:

```bash
cd /srv/baserow
docker compose down
sudo rm -rf /srv/baserow/data
sudo tar -xzf /srv/baserow/backups/baserow-$(date +%F).tar.gz -C /srv/baserow
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/settings/ | grep -o '"allow_new_signups":[a-z]*'
```

You should see: `"allow_new_signups":false`, which means the setting you saved a few minutes ago
came back out of the archive, and so did the account you created.

If you do not: untar with sudo, always. The archive carries the uid 9999 that PostgreSQL owns
its cluster as, and an extract that flattens those owners gives a container that starts and a
database that does not. Two things worth knowing about that archive before you rely on it. It
holds a raw copy of the PostgreSQL data directory, so it restores into this same image and this
same major version and not into a PostgreSQL you install somewhere else. And it holds .env,
which is the only copy of the two keys from step 3.

## 9. Updating later

New versions are listed at https://github.com/baserow/baserow/releases. Take the backup from
step 8 first, then edit the `image:` line in /srv/baserow/compose.yml to the new tag and its
digest.

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

You should see: migration output, then the line `Baserow is now available at`, 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, because a container that answers `OK`
can still be part-way through a migration that stopped.

## 10. What will probably go wrong

The wait. I brought this up on a 4 GB box, watched the browser return `502` for eleven minutes,
decided the reverse proxy was wrong and started taking the Caddy block apart. Nothing was wrong.
A first boot initialises a PostgreSQL cluster, runs every Django migration in order and then
imports the built-in templates, and none of that answers a request. The way to tell waiting from
broken is the loop in step 7: while it prints `502` or `000` under fifteen minutes it is still
coming up, and past fifteen minutes something is actually wrong. Read the log rather than the
browser, with `docker compose logs -f baserow`.

## 11. Out of scope

- Do not set `BASEROW_CADDY_ADDRESSES` to `:443` or to an https URL. That makes the container
  ask Let's Encrypt for its own certificate on a hostname the host Caddy already holds.
- Do not configure SMTP. Baserow runs without it; what it costs is invitation mail and
  password-reset mail, and that is a decision you make later, not a step here.
- Do not point `DATABASE_URL` or `REDIS_URL` at anything outside the container. The embedded
  pair is the shape of this install, and moving them is a migration rather than a setting.
- Do not add a licence key or install any premium or enterprise feature. This install is the
  free edition, which is the MIT-licensed part of the repository.
````

## 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 Baserow 2.3.3 under ~/selfhost/baserow, answering at http://localhost:8175.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Baserow is a database several people edit at once, and the only address this one has is
http://localhost:8175, which means "this computer" wherever it is read. The user gets the
grids, the forms, the formulas and the API. The colleague they meant to share a table with
gets a connection error, and so does their own phone.

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. Baserow needs 4096 MB of RAM available and
10 GB free on the home disk; the image publishes amd64 and arm64. That floor is real: one
container runs a PostgreSQL 15 and a Redis next to the Django backend, the Nuxt web frontend and
a Caddy of its own, and upstream asks for 2 vCPU and 4 GB per container even with the database
outside it. On macOS and Windows the number printed is the host's, and Docker Desktop takes its
allocation out of that. If available RAM is under 4096 MB or free disk is under 10 GB, print
both numbers 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/baserow/backups
ls -la ~/selfhost/baserow
```

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 PostgreSQL inside the
container chowns its cluster to uid 9999 and Windows file sharing will not grant that on a
home-directory bind mount. The archive step 8 writes lands in `backups`, a real folder the user
can open in Finder or Explorer.

## 4. Secrets

Two secrets: the Django secret key and the JWT signing key. The image generates a pair of its
own inside the data volume if none arrive from outside, and values set from outside win, so
generate them here where they land in a file the user keeps. Print neither, and keep both out of
your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/baserow/.env <<EOF
BASEROW_PUBLIC_URL=http://localhost:8175
SECRET_KEY=$(openssl rand -hex 32)
BASEROW_JWT_SIGNING_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/baserow/.env
umask 022
ls -l ~/selfhost/baserow/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. `BASEROW_PUBLIC_URL` carries the port because the browser address
does, and Baserow compares it against the address requests arrive on. Tell the user to read the
pair back once with `grep -E 'SECRET_KEY|SIGNING_KEY' ~/selfhost/baserow/.env` and keep both:
the first signs session cookies, the second signs every API token, and a restore without them
logs everybody out.

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/baserow/compose.yml <<'EOF'
# Baserow · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://baserow.io/docs/installation%2Finstall-with-docker
#   variable reference . https://baserow.io/docs/installation%2Fconfiguration
#   capacity guidance .. https://baserow.io/docs/installation%2Finstall-on-aws
#   supported versions . https://baserow.io/docs/installation%2Fsupported
#
# One service on the computer you are sitting at, and it is five processes
# wearing one hat: a PostgreSQL 15 and a Redis run inside this container next
# to the Django backend, the Nuxt web frontend and a Caddy of its own. All of
# it lands in /baserow/data, which is a named volume here rather than a
# relative bind mount, because that embedded PostgreSQL chowns its data
# directory to uid 9999 and Windows file sharing cannot grant that chown on a
# home-directory folder. ./backups stays a real folder you can open in Finder
# or Explorer.
#
# BASEROW_PUBLIC_URL carries the port because the browser address does. Nothing
# terminates TLS here, so the inner Caddy stays on :80 and no proxy header is
# claimed. Digest read on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  baserow:
    image: baserow/baserow:2.3.3@sha256:41adb3493379403946a493f30873f743bb65b19b5f387d630ec75f41e25d5b5b
    container_name: baserow
    restart: unless-stopped
    env_file: ./.env
    environment:
      # :80 keeps the inner Caddy on plain http and off the ACME path.
      BASEROW_CADDY_ADDRESSES: ":80"
      # One celery worker running both the fast and the slow queue. Upstream
      # names this pair as the way to lower the image's memory use, and the
      # price is that a large export can delay a realtime row update.
      BASEROW_AMOUNT_OF_WORKERS: "1"
      BASEROW_RUN_MINIMAL: "yes"
    volumes:
      - baserow-data:/baserow/data
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8175.
      - "127.0.0.1:8175:80"

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

Assert: that prints `compose OK`. One service, one published port, one named volume.

## 6. Nothing is public

No proxy on this host, 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 editor's crypto still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8175 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 database meant to be shared, that is the
shape of the trade. Confirm it:

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

Assert: that prints `1`. The PostgreSQL and the Redis live inside the container and are never
published at all.

## 7. Start and verify

The first boot is slow. The pull is over a gigabyte, then PostgreSQL initialises a cluster,
Django runs every migration from scratch and the built-in templates import in the background.
Upstream's deployment guide asks for a 900-second grace period on a first start, and this loop
allows exactly that.

```bash
cd ~/selfhost/baserow
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:8175/api/_health/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8175/api/_health/
curl -sS http://localhost:8175/api/settings/ | grep -o '"show_admin_signup_page":[a-z]*'
```

Assert, all three, and print what you received for each. The loop ends printing `200`. The
health endpoint answers with the two characters `OK` and nothing else. The third command prints
`"show_admin_signup_page":true`, meaning no account exists yet. If any of the three misses,
stop, run `docker compose logs --tail 60 baserow`, 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 8175 (`lsof -nP -iTCP:8175 -sTCP:LISTEN`, or
`netstat -ano | findstr :8175` on Windows). A running container is not success.

The first screen at http://localhost:8175 is the sign-up form. Baserow sends the login page
straight to it while no account exists, and it carries the notice `Welcome to Baserow!` above
the line `Please fill the form below to create the admin user.`

STOP: tell the user to open http://localhost:8175, fill that form in, and wait. Do not continue
until they confirm. The first account created on an instance is given staff rights, which is
what makes it the administrator. Tell them to put the password in their password manager as they
type it: there is no mail here, so there is no reset link.

## 8. First backup and restore

One archive, taken with the container stopped, because a tar of a live PostgreSQL is not a
backup. The tar runs inside a throwaway container so the uid 9999 that PostgreSQL owns its
cluster as survives into the archive:

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

Assert: the archive exists and is non-empty. Print its size. On a fresh install with the
templates imported it runs to a few hundred megabytes, and the stop and start cost about a
minute.

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 that leaves this computer, a folder their sync service
watches or a USB stick, and copy it there with `cp`, together with `~/selfhost/baserow/.env`,
which is the only copy of the two keys from step 4. In Git Bash a Windows drive is written
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are listed there. If
they have neither, say plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/baserow`, 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 as above with
`tar -xzf` and the archive's filename in place of `tar -czf` and the date, still extracting with
`-C /baserow`. Then `docker compose up -d` and re-run step 7's check. That archive holds a raw
copy of the PostgreSQL data directory, so it restores into this same image and no other. Those
commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/baserow/baserow/releases. Take step 8's backup
first, then edit the image line in ~/selfhost/baserow/compose.yml to the new tag and digest:

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

Baserow migrates its own database on the way up and prints `Baserow is now available at` when it
has finished. Watch for that line, then re-run step 7's check.

## 10. What will probably go wrong

Docker Desktop's memory cap. I gave this a laptop with 16 GB in it and watched the container
restart in a loop for a quarter of an hour, reading the log for a mistake that was not there.
Docker Desktop was handing its virtual machine 2 GB, and a PostgreSQL, a Redis, a Django and a
Node server do not fit in 2 GB. The number step 1 printed was the laptop's, not Docker's. Open
Docker Desktop, Settings, Resources, give it at least 4 GB, apply and 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 8175 to 0.0.0.0 so a colleague on the wifi can open a table. That publishes a
  database whose sign-up page is open onto every network this computer joins.
- Do not set `BASEROW_CADDY_ADDRESSES` to `:443` or to an https URL. The container would ask
  Let's Encrypt for a certificate that no public name backs.
- Do not configure SMTP, and do not point `DATABASE_URL` or `REDIS_URL` outside the container.
  The embedded pair is the shape of this install.
````

## docker-compose.yml

```yaml
# Baserow · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://baserow.io/docs/installation%2Finstall-with-docker
#   variable reference . https://baserow.io/docs/installation%2Fconfiguration
#   capacity guidance .. https://baserow.io/docs/installation%2Finstall-on-aws
#   supported versions . https://baserow.io/docs/installation%2Fsupported
#
# One service, and it is five processes wearing one hat. Upstream's all-in-one
# image runs a PostgreSQL 15 and a Redis inside this same container next to the
# Django backend, the Nuxt web frontend and a Caddy of its own, and keeps every
# byte of that under /baserow/data. That is why no database service appears
# below, and why the RAM floor is 4 GB rather than the few hundred megabytes a
# web application on its own would want.
#
# BASEROW_CADDY_ADDRESSES is pinned to :80 so the container's Caddy serves plain
# http and never asks Let's Encrypt for anything. The host Caddy already holds
# the hostname and terminates TLS, so only the container's port 80 is published.
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The image carries its own HEALTHCHECK, so this file adds
# none.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  baserow:
    image: baserow/baserow:2.3.3@sha256:41adb3493379403946a493f30873f743bb65b19b5f387d630ec75f41e25d5b5b
    container_name: baserow
    restart: unless-stopped
    env_file: /srv/baserow/.env
    environment:
      # :80 keeps the inner Caddy on plain http and off the ACME path.
      BASEROW_CADDY_ADDRESSES: ":80"
      # The host Caddy terminates TLS and sets X-Forwarded-Proto itself, which
      # is the condition upstream names for turning this on. Without it the
      # paginated API hands out http:// links for an https-only service.
      BASEROW_ENABLE_SECURE_PROXY_SSL_HEADER: "yes"
      # One celery worker running both the fast and the slow queue. Upstream
      # names this pair as the way to lower the image's memory use, and the
      # price is that a large export can delay a realtime row update.
      BASEROW_AMOUNT_OF_WORKERS: "1"
      BASEROW_RUN_MINIMAL: "yes"
    volumes:
      - /srv/baserow/data:/baserow/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8175.
      - "127.0.0.1:8175:80"
```

## compose.local.yml

```yaml
# Baserow · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://baserow.io/docs/installation%2Finstall-with-docker
#   variable reference . https://baserow.io/docs/installation%2Fconfiguration
#   capacity guidance .. https://baserow.io/docs/installation%2Finstall-on-aws
#   supported versions . https://baserow.io/docs/installation%2Fsupported
#
# One service on the computer you are sitting at, and it is five processes
# wearing one hat: a PostgreSQL 15 and a Redis run inside this container next
# to the Django backend, the Nuxt web frontend and a Caddy of its own. All of
# it lands in /baserow/data, which is a named volume here rather than a
# relative bind mount, because that embedded PostgreSQL chowns its data
# directory to uid 9999 and Windows file sharing cannot grant that chown on a
# home-directory folder. ./backups stays a real folder you can open in Finder
# or Explorer.
#
# BASEROW_PUBLIC_URL carries the port because the browser address does. Nothing
# terminates TLS here, so the inner Caddy stays on :80 and no proxy header is
# claimed. Digest read on 2026-08-07; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  baserow:
    image: baserow/baserow:2.3.3@sha256:41adb3493379403946a493f30873f743bb65b19b5f387d630ec75f41e25d5b5b
    container_name: baserow
    restart: unless-stopped
    env_file: ./.env
    environment:
      # :80 keeps the inner Caddy on plain http and off the ACME path.
      BASEROW_CADDY_ADDRESSES: ":80"
      # One celery worker running both the fast and the slow queue. Upstream
      # names this pair as the way to lower the image's memory use, and the
      # price is that a large export can delay a realtime row update.
      BASEROW_AMOUNT_OF_WORKERS: "1"
      BASEROW_RUN_MINIMAL: "yes"
    volumes:
      - baserow-data:/baserow/data
      - ./backups:/backup
    ports:
      # Loopback only: no other device on the wifi can reach 8175.
      - "127.0.0.1:8175:80"

volumes:
  baserow-data:
```

## Caddyfile

```text
# Baserow · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://baserow.io/docs/installation%2Finstall-with-docker,
# https://baserow.io/docs/installation%2Fconfiguration and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASEROW_PUBLIC_URL in .env. The container's own Caddy decides whether a
# request is for Baserow by comparing the Host header against that value, so
# the two have to stay the same string or the API routes stop answering.

<DOMAIN> {
	# The web frontend is a large JavaScript bundle and the API answers JSON.
	# The container's Caddy compresses neither, so this is the only place it
	# happens. Uploaded files are served from the same hostname and Caddy
	# leaves already-compressed images alone.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# Realtime collaboration holds a websocket open on /ws/, and reverse_proxy
	# carries that upgrade with no extra directive. Caddy also sets
	# X-Forwarded-Proto here, which is what lets the container be told it is
	# behind https.
	#
	# 8175 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8175
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Baserow · 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=base.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://baserow.io/docs/installation%2Finstall-with-docker
#   https://baserow.io/docs/installation%2Fconfiguration
#   https://baserow.io/docs/installation%2Finstall-on-aws
#   https://baserow.io/docs/installation%2Fsupported
#
# Two secrets are generated here, on this machine: the Django secret key and the
# JWT signing key. Both go into /srv/baserow/.env with mode 600 and neither is
# ever printed. The image would invent its own pair inside the data volume if
# these were absent; keeping them in .env is what lets one archive carry both.
#
# DOMAIN_HOST is also BASEROW_PUBLIC_URL. The Caddy inside the container matches
# the Host header against it, so the two have to stay the same string.
#
# This script leaves two things for a human: creating the first account, which
# becomes the administrator, and turning off new signups afterwards.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/baserow}"
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. base.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"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; the all-in-one image wants 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 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: the container starts as root, chowns it to uid 9999
# and only then drops privileges, and the PostgreSQL inside it refuses to
# initialise in a directory somebody else already owns.

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 two secrets, on the server ------------------------------
#
# Hex rather than base64: both are read by a shell before Django sees them and
# neither wants escaping. Read them later with
#   sudo grep -E 'SECRET_KEY|SIGNING_KEY' /srv/baserow/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BASEROW_PUBLIC_URL=https://${DOMAIN_HOST}
		SECRET_KEY=$(openssl rand -hex 32)
		BASEROW_JWT_SIGNING_KEY=$(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-baserow"
	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 8175 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; 8175, 5432 and 6379 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 initialises a PostgreSQL cluster, runs every Django migration
# and imports the built-in templates. Upstream's deployment guide asks for a
# 900-second grace period on a first start; this loop allows exactly that.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/_health/ (up to 15 minutes)"
for _ in $(seq 1 60); 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 baserow"

curl -sS "https://${DOMAIN_HOST}/api/_health/" | grep -q '^OK$' \
	|| die "health answered 200 without the body OK. Check: docker compose logs --tail 60 baserow"

# No account exists yet, and the instance says so. A false here on a fresh
# install means somebody else has already claimed the administrator account.
curl -sS "https://${DOMAIN_HOST}/api/settings/" | grep -q '"show_admin_signup_page":true' \
	|| die "this instance already has an account. Stop, run: docker compose down && sudo rm -rf ${APP_DIR}/data, then rerun this script."

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, because a PostgreSQL cluster and a Redis are writing in there and a
# tar of a live database is a file that looks like a backup.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/baserow-${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/baserow-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Baserow is answering at https://${DOMAIN_HOST}/api/_health/

	  1. Open https://${DOMAIN_HOST} and create the first account now. The form
	     is headed "Welcome to Baserow!" over "Please fill the form below to
	     create the admin user." That account is given staff rights, which is
	     what makes it the administrator, and until it exists anyone reaching
	     this hostname can claim it.
	  2. Then open https://${DOMAIN_HOST}/admin/settings and turn off
	     "Allow creating new accounts". Confirm from here with
	       curl -sS https://${DOMAIN_HOST}/api/settings/
	     which should now report allow_new_signups false. Leaving it on leaves
	     signups open to every visitor.
	  3. There is no mail server here, so there is no password reset. Put that
	     password in your password manager as you type it.
	  4. Your two keys are in $APP_DIR/.env, mode 600. Read them with
	       sudo grep -E 'SECRET_KEY|SIGNING_KEY' $APP_DIR/.env
	     and keep them: a restore without them signs everybody out. They were
	     not printed here.
	  5. 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/baserow/
	     Restore is: docker compose down, sudo rm -rf $APP_DIR/data,
	     sudo tar -xzf the archive -C $APP_DIR, docker compose up -d.

DONE
```

## Also evaluated

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

- **Grist** — A spreadsheet with a real database underneath: Python formulas, linked records and row-level access rules, in one container. Second place here, and the answer if the spreadsheet half is what you came for rather than the Baserow half. Grist gives you formulas written in Python instead of a bespoke expression language, access rules that reach down to the individual row, and documents that are plain SQLite files you can copy, all from a single container with no database process at all. It is the lighter install of the two and it lands in an evening. What it is not is Baserow: you leave behind the interface you already know, the automations, and any chance of moving between the hosted plan and your own server without an export.

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