Can I self-host Airtable?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Grist. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $100 a month you stop paying Airtable — $1,200 a year on the Team plan, 5 seats assumed.
Why people pay for Airtable
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Airtable sells the thing a spreadsheet is worst at: one shared table that a whole team edits at the same time, with nobody emailing a file around and nobody wondering which copy is current. On top of that it stacks views, forms, linked records and interfaces, so a team with no engineer can assemble something that behaves like an internal app in an afternoon and keep it running for years. The bill is per editor per month, which means it grows with the number of people allowed to change anything rather than with the amount of data, and the pieces teams come to depend on fastest, the automations and the two-way sync connectors, are the pieces that sit above the free plan.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | The page describes it as available to teams at no charge. Its per-base record, automation-run and attachment caps are listed on the same page and are not reproduced here. |
| Teamthe plan this page prices against | $20/mo per seat | The page prints $20/user/month when billed annually, and says it charges for all users who have edit permissions for at least one base. No monthly-billed rate appears on the page. |
| Business | $45/mo per seat | The page prints $45/user/month when billed annually, on the same edit-permission seat count. No monthly-billed rate appears on the page. |
| Enterprise Scale | quote only | Quote only. The page says pricing is custom, based on the organization's needs. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Grist
One project, named before the prompt, so you know what you are about to install.
A spreadsheet with a real database underneath: Python formulas, linked records and row-level access rules, in one container.
Gives you the part of Airtable that holds the data, and is honest that it is not the rest. Grids, column types, linked records across tables, formulas written in Python rather than a bespoke expression language, and access rules that reach down to the individual row. It runs from one container with its documents as SQLite files you can copy, which is why it installs in an evening rather than a weekend. What you take on is the login: grist-core ships no username-and-password screen of its own, so the install puts one in front of it, and what you do not get is Automations, Interfaces, the app marketplace or the sync connectors.
The swap
You'd run
Grist
ONE EVENING · ~90 min to running · 1024 MB RAM
Airtable Team · 5 seats assumed · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Grist: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
317 lines · 14,469 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Grist 1.7.17 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>` is the address that becomes the administrator of this Grist installation and
also the username on the login box in front of it, so it is typed at every sign-in.
Grist needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64. Measure all four before doing anything else:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
caddy version
```
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. `caddy version` must print 2.8 or newer: the site block in step 5 uses
the `basic_auth` directive, which is what 2.8 renamed `basicauth` to. If it is older, stop and
tell the user to upgrade Caddy first.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/grist /srv/grist/backups
sudo install -d -m 700 /srv/grist/persist
ls -la /srv/grist
```
Assert: `ls -la` shows `backups` owned by the login user and `persist` at mode `700`. Leave
`persist` alone after this. The Grist image starts as root, chowns everything under /persist to
its own unprivileged user, and only then drops to that user, so an ownership fix here would be
undone on the next start. Everything the install holds lives under /srv/grist: documents are
`.grist` SQLite files in `persist/docs`, and the account table is `persist/home.sqlite3`.
## 3. Secrets
Two secrets, both generated here on the server. Print neither, repeat neither in your summary,
and put neither in a log line. The first replaces a session key whose default value is
published in the Grist source. The second is the password on the login box, and it is the only
thing standing between the public internet and this database.
```bash
umask 077
cat > /srv/grist/.env <<EOF
APP_HOME_URL=https://<DOMAIN>
GRIST_DEFAULT_EMAIL=<ADMIN_EMAIL>
GRIST_SESSION_SECRET=$(openssl rand -hex 32)
EOF
openssl rand -hex 24 > /srv/grist/browser-login
chmod 600 /srv/grist/.env /srv/grist/browser-login
umask 022
ls -l /srv/grist/.env /srv/grist/browser-login
```
Assert: both files exist with mode `-rw-------`. Hex rather than base64, because the login
value gets typed into a browser dialog and hex has nothing in it a keyboard layout can ruin.
The login password is deliberately not in `.env`: `.env` is what compose hands to the
container, and the credential the user types belongs nowhere near the application's
environment.
## 4. compose.yml
```bash
cat > /srv/grist/compose.yml <<'EOF'
# Grist · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# self-managed ....... https://support.getgrist.com/self-managed/
# forwarded headers .. https://support.getgrist.com/install/forwarded-headers/
# env var reference .. https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# upstream examples .. https://github.com/gristlabs/grist-core/tree/v1.7.17/docker-compose-examples
#
# One service. Documents are .grist SQLite files under /persist/docs and the
# account table is /persist/home.sqlite3, so there is no database process to run
# and nothing to dump. grist-core ships no username-and-password login of its
# own: the host Caddy checks the credential and passes the address it verified
# in X-Forwarded-User, which is what GRIST_FORWARD_AUTH_HEADER together with
# GRIST_IGNORE_SESSION tells Grist to trust on every request. Tag and digest
# were read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
grist:
image: gristlabs/grist-oss:1.7.17@sha256:b87ec1c3b62ca99f872611a9aa71ca33ee5fef9f40e0921e0beed878e5083473
container_name: grist
restart: unless-stopped
environment:
# These three come from /srv/grist/.env, which is mode 600. Compose reads
# that file for substitution because it sits beside this one.
APP_HOME_URL: ${APP_HOME_URL}
GRIST_DEFAULT_EMAIL: ${GRIST_DEFAULT_EMAIL}
GRIST_SESSION_SECRET: ${GRIST_SESSION_SECRET}
# Trust this header, and only this header, for identity. Caddy overwrites
# it on every proxied request, so a browser cannot put a name in it.
GRIST_FORWARD_AUTH_HEADER: X-Forwarded-User
GRIST_IGNORE_SESSION: "true"
GRIST_FORCE_LOGIN: "true"
# One team site, so no /o/<team> prefix turns up in any URL.
GRIST_SINGLE_ORG: grist
# Skip the first-run Quick setup gate, which would otherwise ask for a
# boot key pasted out of the container log.
GRIST_IN_SERVICE: "true"
# Formulas are Python running on this server. On a public host they run
# inside gvisor rather than directly.
GRIST_SANDBOX_FLAVOR: gvisor
volumes:
# The image chowns everything under /persist to its own user on start,
# then drops out of root, so this directory is left alone after step 2.
- /srv/grist/persist:/persist
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8101.
- "127.0.0.1:8101:8484"
EOF
cd /srv/grist && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Nothing is published beyond 127.0.0.1, and there is no
second service, because Grist keeps its documents and its account table in SQLite files inside
/persist.
## 5. Caddy and TLS
Two files. First the credential Caddy checks, which is a bcrypt hash of the password step 3
generated, written where the caddy user can read it and nowhere else:
```bash
umask 077
caddy hash-password < /srv/grist/browser-login > /srv/grist/grist-auth.hash
printf 'basic_auth {\n\t%s %s\n}\n' '<ADMIN_EMAIL>' "$(cat /srv/grist/grist-auth.hash)" > /srv/grist/grist-auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/grist/grist-auth.conf /etc/caddy/grist-auth.conf
rm -f /srv/grist/grist-auth.hash /srv/grist/grist-auth.conf
sudo grep -c basic_auth /etc/caddy/grist-auth.conf
```
Assert: that prints `1`. Reading the password from a file rather than passing it as an
argument keeps it out of the process list.
Then the site block, appended 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-grist
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Grist · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.getgrist.com/install/forwarded-headers/,
# https://support.getgrist.com/self-managed/ and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. grist-core has no
# username-and-password login of its own, so this block is the login: Caddy
# checks the credential and then tells Grist which address it verified. Needs
# Caddy 2.8 or newer, which is where the directive is spelled basic_auth.
<DOMAIN> {
# Grist ships a large JavaScript bundle, so compression is worth having.
# WebSocket upgrades pass through untouched.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The credential is not in this file, because this file is published. The
# install writes /etc/caddy/grist-auth.conf with one basic_auth block: the
# username, and a bcrypt hash of the password it generated. That file is
# mode 640, owned by root and readable by the caddy group.
import /etc/caddy/grist-auth.conf
# 8101 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:8101 {
# Set, not add. Whatever a browser sent under this name is replaced by
# the username basic_auth has verified, so the header cannot be
# spoofed, which is the one thing this whole arrangement depends on.
header_up X-Forwarded-User {http.auth.user.id}
}
}
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-grist, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it on its own; 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. 8101 stays closed because it is bound to 127.0.0.1, and opening it would
route around the login box in step 5 entirely. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8101.
## 7. Start and verify
```bash
cd /srv/grist
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8101/status?db=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS 'http://127.0.0.1:8101/status?db=1'
docker compose logs grist | grep -c 'gvisor check ok'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
Assert, all four, and print what you received for each. The loop ends printing `200`. The
status response contains `is alive` and `db ok`. The log grep prints `1`, which is the
sandbox check upstream runs before the server starts. The last curl prints `401`: that is
Caddy refusing an unauthenticated request, and it is the security assert in this block,
because an unauthenticated Grist on a public hostname is an open database. If any of the four
misses, stop, run `docker compose logs --tail 40 grist`, and say which earlier step is the
likely cause. `gvisor check failed` in that log means this kernel will not run the sandbox and
the container exits on purpose; do not switch the sandbox off to get past it.
STOP: tell the user to open https://<DOMAIN>, sign in with `<ADMIN_EMAIL>` and the password
they read with `cat /srv/grist/browser-login`, put that password in their password manager,
then create one document and put a number in a cell. Wait. Do not continue until they confirm.
The first screen after signing in shows `Create empty document`. A running container is not
success, and neither is a login box: the document has to open, because that is the part that
uses the WebSocket the proxy has to carry.
## 8. First backup and restore
One archive. Stop the container first: the documents are SQLite files, and a copy taken
mid-write is not a backup.
```bash
cd /srv/grist
docker compose stop
sudo tar -czf /srv/grist/backups/grist-$(date +%F).tar.gz -C /srv/grist persist .env browser-login compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/grist/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds.
That archive contains both secrets, so it is as sensitive as the data. A backup on the same
disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/grist
scp vps:/srv/grist/backups/*.tar.gz ~/backups/grist/
```
To restore: `docker compose down`, `sudo rm -rf /srv/grist/persist`, untar the archive back
into /srv/grist, re-run the first fence of step 5 to rebuild /etc/caddy/grist-auth.conf from
the restored `browser-login`, `sudo systemctl reload caddy`,
then `docker compose up -d` and re-run the four asserts from step 7. Tell the user those are
the whole disaster plan.
## 9. Updating later
New versions are listed at https://github.com/gristlabs/grist-core/releases. Take the backup
first, then edit the image line in /srv/grist/compose.yml to the new tag and its digest:
```bash
cd /srv/grist
docker compose pull
docker compose up -d
docker compose logs --tail 30 grist
```
Grist migrates its own SQLite files on the way up, so watch that log until it settles, then
re-run the four asserts from step 7 before calling the update done.
## 10. What will probably go wrong
The sandbox. I set `GRIST_SANDBOX_FLAVOR=gvisor` because formulas in Grist are Python running
on this server, and that turns a start-up check into a hard gate: upstream's own start script
runs `runsc` once before the server, prints `gvisor check ok` or `gvisor check failed`, and
exits on failure. A container stuck in that loop looks fine from the outside, which is the
part that cost me time: `docker compose ps` reports it as restarting rather than as broken,
and the reason is only ever in the log. It is not a Grist bug, and it is not something to
paper over by dropping the sandbox: the honest options are a VPS whose kernel hosts gvisor, or
a deliberate decision by the user that they will only open documents they wrote themselves.
Read the log before you conclude anything else is wrong.
## 11. Out of scope
- Do not configure OIDC, SAML or any identity provider. Caddy's login box is the whole auth
story here, and a second one would leave two doors into the same install.
- Do not set `GRIST_BOOT_KEY` or open the Quick setup page. `GRIST_IN_SERVICE` skips that gate
on purpose, and the address in `GRIST_DEFAULT_EMAIL` is already the installation admin.
- Do not configure SMTP, and do not set `ASSISTANT_API_KEY` or `OPENAI_API_KEY`. Grist runs
without mail, and the formula assistant is a paid account somewhere else.
- Do not add Redis, PostgreSQL or MinIO. Those belong to the multi-worker setup upstream
documents separately; this install is one container with SQLite files.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Grist 1.7.17 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 `<ADMIN_EMAIL>` with the address you want to be the administrator of this Grist
installation.
Read this before step 1, because it is the fact that shapes the whole install. grist-core
ships no username-and-password login of its own. Left on a public hostname without one in
front of it, it is an open database. What goes in front of it here is Caddy: a login box
Caddy checks, and then Caddy tells Grist which address it verified. `<ADMIN_EMAIL>` is
both that verified address and the username you type at every sign-in, so pick the one you
will not want to change.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
caddy version
dig +short <DOMAIN>
```
You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, a Caddy
version of `2.8` or newer, 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, and 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.
A Caddy older than 2.8 is the other blocker: step 5 uses the `basic_auth` directive, which is
what 2.8 renamed `basicauth` to, and an older binary will reject the file. Upgrade Caddy
before you go on.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/grist /srv/grist/backups
sudo install -d -m 700 /srv/grist/persist
ls -la /srv/grist
```
You should see: `backups` owned by you, and `persist` at mode `drwx------` owned by root.
If you do not: leave `persist` owned by root on purpose. The Grist image starts as root,
chowns everything under /persist to its own unprivileged user, and only then drops to that
user, so anything you set here is replaced on the first start. Documents will land in
`persist/docs` as `.grist` SQLite files, and the account table is `persist/home.sqlite3`.
## 3. Secrets
Two secrets, both generated here on the server, both landing in files only you can read. The
first replaces a session key whose default value is published in the Grist source. The second
is the password on the login box, and it is the only thing standing between the public
internet and this database.
```bash
umask 077
cat > /srv/grist/.env <<EOF
APP_HOME_URL=https://<DOMAIN>
GRIST_DEFAULT_EMAIL=<ADMIN_EMAIL>
GRIST_SESSION_SECRET=$(openssl rand -hex 32)
EOF
openssl rand -hex 24 > /srv/grist/browser-login
chmod 600 /srv/grist/.env /srv/grist/browser-login
umask 022
ls -l /srv/grist/.env /srv/grist/browser-login
```
You should see: two files, both mode `-rw-------`, both owned by you. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` on those two lines with your real values before you paste. Read the login
password once with `cat /srv/grist/browser-login` and put it in your password manager now:
you will type it, with `<ADMIN_EMAIL>` as the username, every time you open the site.
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/grist/.env /srv/grist/browser-login` and carry on. If either file already
existed from an earlier attempt, this block has now overwritten it, which is harmless before
step 5 and a locked-out login afterwards, because the hash Caddy checks was built from the old
value.
Do not paste `.env`, the login password, or any command output containing either into this
chat window. Hex rather than base64 for both, because the login value gets typed into a
browser dialog and hex has nothing in it a keyboard layout can ruin.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/grist/compose.yml <<'EOF'
# Grist · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# self-managed ....... https://support.getgrist.com/self-managed/
# forwarded headers .. https://support.getgrist.com/install/forwarded-headers/
# env var reference .. https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# upstream examples .. https://github.com/gristlabs/grist-core/tree/v1.7.17/docker-compose-examples
#
# One service. Documents are .grist SQLite files under /persist/docs and the
# account table is /persist/home.sqlite3, so there is no database process to run
# and nothing to dump. grist-core ships no username-and-password login of its
# own: the host Caddy checks the credential and passes the address it verified
# in X-Forwarded-User, which is what GRIST_FORWARD_AUTH_HEADER together with
# GRIST_IGNORE_SESSION tells Grist to trust on every request. Tag and digest
# were read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
grist:
image: gristlabs/grist-oss:1.7.17@sha256:b87ec1c3b62ca99f872611a9aa71ca33ee5fef9f40e0921e0beed878e5083473
container_name: grist
restart: unless-stopped
environment:
# These three come from /srv/grist/.env, which is mode 600. Compose reads
# that file for substitution because it sits beside this one.
APP_HOME_URL: ${APP_HOME_URL}
GRIST_DEFAULT_EMAIL: ${GRIST_DEFAULT_EMAIL}
GRIST_SESSION_SECRET: ${GRIST_SESSION_SECRET}
# Trust this header, and only this header, for identity. Caddy overwrites
# it on every proxied request, so a browser cannot put a name in it.
GRIST_FORWARD_AUTH_HEADER: X-Forwarded-User
GRIST_IGNORE_SESSION: "true"
GRIST_FORCE_LOGIN: "true"
# One team site, so no /o/<team> prefix turns up in any URL.
GRIST_SINGLE_ORG: grist
# Skip the first-run Quick setup gate, which would otherwise ask for a
# boot key pasted out of the container log.
GRIST_IN_SERVICE: "true"
# Formulas are Python running on this server. On a public host they run
# inside gvisor rather than directly.
GRIST_SANDBOX_FLAVOR: gvisor
volumes:
# The image chowns everything under /persist to its own user on start,
# then drops out of root, so this directory is left alone after step 2.
- /srv/grist/persist:/persist
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8101.
- "127.0.0.1:8101:8484"
EOF
cd /srv/grist && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page
and your terminal, so run `rm /srv/grist/compose.yml` and paste again in one go. A warning
that `APP_HOME_URL` is not set means you are not in /srv/grist, or step 3 did not write
`.env`; compose reads that file for substitution because it sits beside compose.yml, so the
`cd` is not optional. There is no second service on purpose: Grist keeps documents and
accounts in SQLite files inside /persist, so there is no database to run and none to dump.
## 5. Caddy and TLS
Two files. First the credential Caddy will check. It is a bcrypt hash of the password step 3
generated, written where the caddy user can read it and nowhere else.
```bash
umask 077
caddy hash-password < /srv/grist/browser-login > /srv/grist/grist-auth.hash
printf 'basic_auth {\n\t%s %s\n}\n' '<ADMIN_EMAIL>' "$(cat /srv/grist/grist-auth.hash)" > /srv/grist/grist-auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/grist/grist-auth.conf /etc/caddy/grist-auth.conf
rm -f /srv/grist/grist-auth.hash /srv/grist/grist-auth.conf
sudo grep -c basic_auth /etc/caddy/grist-auth.conf
```
You should see: `1`.
If you do not: `chown: invalid group: 'root:caddy'` means Caddy was installed some way that
did not create a `caddy` group, so use the group the Caddy service actually runs as, which
`systemctl show -p User -p Group caddy` will tell you. Reading the password from a file rather
than passing it on the command line is deliberate: an argument is visible in the process list
to anyone else on the box.
Now the site block. Replace `<DOMAIN>` in it 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-grist
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Grist · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.getgrist.com/install/forwarded-headers/,
# https://support.getgrist.com/self-managed/ and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. grist-core has no
# username-and-password login of its own, so this block is the login: Caddy
# checks the credential and then tells Grist which address it verified. Needs
# Caddy 2.8 or newer, which is where the directive is spelled basic_auth.
<DOMAIN> {
# Grist ships a large JavaScript bundle, so compression is worth having.
# WebSocket upgrades pass through untouched.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The credential is not in this file, because this file is published. The
# install writes /etc/caddy/grist-auth.conf with one basic_auth block: the
# username, and a bcrypt hash of the password it generated. That file is
# mode 640, owned by root and readable by the caddy group.
import /etc/caddy/grist-auth.conf
# 8101 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:8101 {
# Set, not add. Whatever a browser sent under this name is replaced by
# the username basic_auth has verified, so the header cannot be
# spoofed, which is the one thing this whole arrangement depends on.
header_up X-Forwarded-User {http.auth.user.id}
}
}
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-grist /etc/caddy/Caddyfile`, reload,
and paste again. `unrecognized directive: basic_auth` is the Caddy version problem from step 1
showing up late. `import: no files matching` means the first fence in this step did not write
/etc/caddy/grist-auth.conf. Caddy requests the certificate on the first request to the
hostname and renews it on its own, so there is nothing to schedule.
## 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 `8101`.
If you do not: delete anything for `8101` with `sudo ufw delete allow 8101`. That port is
bound to 127.0.0.1 by the compose file, and opening it would route around the login box you
built in step 5, which is the entire security of this install. 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
```bash
cd /srv/grist
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8101/status?db=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS 'http://127.0.0.1:8101/status?db=1'
docker compose logs grist | grep -c 'gvisor check ok'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
You should see, in order: the loop reaching `200`, a line containing `is alive` and `db ok`,
then `1`, then `401`.
If you do not: the `401` is the one worth understanding. It means Caddy is refusing a request
that carried no credential, which is exactly what should happen, so seeing it is good news. A
`200` in its place means the login box is not in the path, and you should stop and re-read
step 5 before you put anything real in this install. A `502` means Caddy is up and the
container is not. If the loop never reaches `200`, run `docker compose logs --tail 40 grist`:
`gvisor check failed` there means this kernel will not run the sandbox and the container is
exiting on purpose, which is step 10, and it is not something to fix by switching the sandbox
off.
Now open https://<DOMAIN> in a browser. You will get a login box: the username is
`<ADMIN_EMAIL>`, the password is what `cat /srv/grist/browser-login` prints. The first screen
after signing in shows `Create empty document`. Create one document and put a number in a
cell. A running container is not success, and neither is a login box: the document has to
open, because that is the part that uses the WebSocket the proxy has to carry.
## 8. First backup and restore
One archive. Stop the container first: the documents are SQLite files, and a copy taken
mid-write is not a backup.
```bash
cd /srv/grist
docker compose stop
sudo tar -czf /srv/grist/backups/grist-$(date +%F).tar.gz -C /srv/grist persist .env browser-login compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/grist/backups/
```
You should see: one `.tar.gz`, a few hundred kilobytes on a fresh install, and about ten
seconds of downtime.
If you do not: an archive of a few hundred bytes means `persist` was empty, so the container
never started properly and step 7 was passed too generously. That archive holds both secrets,
so treat it as you would the data.
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/grist
scp vps:/srv/grist/backups/*.tar.gz ~/backups/grist/
```
You should see: one file copied, and listed by `ls -lh ~/backups/grist/`.
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 a test number:
```bash
cd /srv/grist
docker compose down
sudo rm -rf /srv/grist/persist
sudo tar -C /srv/grist -xzf /srv/grist/backups/grist-$(date +%F).tar.gz
docker compose up -d
sleep 30
curl -sS 'http://127.0.0.1:8101/status?db=1'
```
You should see: `is alive` and `db ok` again, and your document with its number still in it
when you reload the browser.
If you do not: if you ever restore onto a box where /etc/caddy/grist-auth.conf is missing or
stale, rebuild it by re-running the first fence of step 5 against the restored
`browser-login`, then `sudo systemctl reload caddy`. Those steps plus this archive are the
whole disaster plan.
## 9. Updating later
New versions are listed at https://github.com/gristlabs/grist-core/releases. Take the backup
first, then edit the `image:` line in /srv/grist/compose.yml to the new tag and its digest.
```bash
cd /srv/grist
docker compose pull
docker compose up -d
docker compose logs --tail 30 grist
```
You should see: the gvisor check line, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run
the four checks from step 7 before you call the update done, and open a real document as well,
because a server that answers `is alive` can still be failing to open documents if a migration
stopped halfway.
## 10. What will probably go wrong
The sandbox. The compose file sets `GRIST_SANDBOX_FLAVOR=gvisor` because formulas in Grist are
Python running on this server, and that turns a start-up check into a hard gate: upstream's
own start script runs `runsc` once before the server, prints `gvisor check ok` or
`gvisor check failed`, and exits on failure. A container stuck in that loop looks fine from
the outside, which is the part that cost me time: `docker compose ps` reports it as restarting
rather than as broken, and the reason is only ever in the log. It is not a Grist bug, and the
honest options are a VPS whose kernel hosts gvisor or a deliberate decision that you will only
open documents you wrote yourself. Read the log before you conclude anything else is wrong.
## 11. Out of scope
- Do not configure OIDC, SAML or any identity provider. Caddy's login box is the whole auth
story here, and a second one leaves two doors into the same install.
- Do not set `GRIST_BOOT_KEY` or open the Quick setup page. `GRIST_IN_SERVICE` skips that gate
on purpose, and the address in `GRIST_DEFAULT_EMAIL` is already the installation admin.
- Do not configure SMTP, and do not set `ASSISTANT_API_KEY` or `OPENAI_API_KEY`. Grist runs
without mail, and the formula assistant is a paid account somewhere else.
- Do not add Redis, PostgreSQL or MinIO. Those belong to the multi-worker setup upstream
documents separately; this install is one container with SQLite files.290 lines · 14,108 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Grist 1.7.17 under ~/selfhost/grist, answering at http://localhost:8101.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. The base will live at http://localhost:8101, which means this computer wherever it is
read, so a colleague they want to share a table with cannot open it and neither can their own
phone. What they get is a spreadsheet-database for one person, on one machine, that nobody
bills them for.
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. Grist needs 1024 MB of RAM available and
5 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints free
memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker
Desktop's virtual machine takes its allocation out of it. If available RAM is under 1024 MB or
free disk is under 5 GB, print both numbers and stop. Do not install and hope.
## 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/grist/persist ~/selfhost/grist/backups
ls -la ~/selfhost/grist
```
Assert: `ls -la` shows `persist` and `backups`, both owned by the user. Do not chown either of
them. On Linux the Grist container starts as root, chowns everything under /persist to its own
unprivileged user and then drops to it, so anything set here is replaced on the first start;
on macOS and Windows, Docker Desktop's file sharing owns that question instead. Documents land
in `persist/docs` as `.grist` SQLite files and the account table is `persist/home.sqlite3`,
both visible in Finder or Explorer.
## 4. Secrets
One secret here. Generate it, print it nowhere, and keep it out of your summary and out of any
log line.
```bash
umask 077
cat > ~/selfhost/grist/.env <<EOF
GRIST_SESSION_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/grist/.env
umask 022
ls -l ~/selfhost/grist/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same
on all three systems. Grist falls back to a session key whose value is published in its own
source when this variable is unset, which is the reason to generate one even here. The server
path for this app generates a second secret, the password on the login box in front of it;
this path has no login box, because step 6 is why it does not need one.
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/grist/compose.yml <<'EOF'
# Grist · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# self-managed ....... https://support.getgrist.com/self-managed/
# env var reference .. https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# upstream examples .. https://github.com/gristlabs/grist-core/tree/v1.7.17/docker-compose-examples
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/grist/, which lets one file work on macOS, Linux and Windows.
# ./persist stays a bind mount rather than a named volume, because upstream's
# own documented command mounts a home directory there and because a reader
# should be able to see their .grist files in Finder or Explorer. Nothing here
# is reachable from another device, so there is no proxy to authenticate at and
# no forward-auth header: GRIST_DEFAULT_EMAIL is the single identity Grist
# attributes edits to, which is the mode upstream documents for a machine with
# no sign-in configured. Digest read from Docker Hub on 2026-08-06; the image
# publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
grist:
image: gristlabs/grist-oss:1.7.17@sha256:b87ec1c3b62ca99f872611a9aa71ca33ee5fef9f40e0921e0beed878e5083473
container_name: grist
restart: unless-stopped
environment:
# Read from ./.env, which is mode 600 and holds the one generated secret.
GRIST_SESSION_SECRET: ${GRIST_SESSION_SECRET}
# The address edits are attributed to. Upstream's own default, kept
# because nothing off this computer can reach the port below.
GRIST_DEFAULT_EMAIL: you@example.com
APP_HOME_URL: http://localhost:8101
# One team site, so no /o/<team> prefix turns up in any URL.
GRIST_SINGLE_ORG: grist
# Skip the first-run Quick setup gate, which would otherwise ask for a
# boot key pasted out of the container log.
GRIST_IN_SERVICE: "true"
volumes:
# The image chowns everything under /persist to its own user on start,
# then drops out of root, so this directory is left alone after step 3.
- ./persist:/persist
ports:
# Loopback only: no other device on the wifi can reach 8101.
- "127.0.0.1:8101:8484"
EOF
cd ~/selfhost/grist && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, one bind mount.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and no sign-in screen. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
- No login. This is the honest part, and the user should hear it in plain words: Grist has no
password login of its own, so on a server this app has to be put behind one. Here the
boundary is the loopback binding instead. Anyone sitting at this unlocked computer can open
the base, exactly as they could open a spreadsheet file.
8101 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. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/grist/compose.yml
```
Assert: one line, `- "127.0.0.1:8101:8484"`. If it ever reads `0.0.0.0:8101`, this install has
no boundary left at all.
## 7. Start and verify
```bash
cd ~/selfhost/grist
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' 'http://localhost:8101/status?db=1'); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS 'http://localhost:8101/status?db=1'
ls -la ~/selfhost/grist/persist
```
Assert all three, and print what you received for each: the loop ends on `200`; the status
response contains `is alive` and `db ok`; the listing shows `docs` and `home.sqlite3`, which
is Grist having written its own files into the bind mount. If any of the three misses, stop,
run `docker compose logs --tail 40 grist`, and name the likely cause. `Invalid permissions,
cannot write '/persist'` points at step 3, where the folder was created or chowned by hand. If
`port is already allocated` came back, find what holds 8101
(`lsof -nP -iTCP:8101 -sTCP:LISTEN`, `ss -ltnp | grep 8101` on Linux,
`netstat -ano | findstr :8101` on Windows) and stop until the user frees it. A running
container is not success.
STOP: tell the user to open http://localhost:8101, create one document and put a number in a
cell, and wait. Do not continue until they confirm. The first screen shows
`Create empty document`. The document has to actually open, because that is the part that uses
the WebSocket, and a home page that lists nothing proves less than one cell that saves.
## 8. First backup and restore
One archive. Stop the container first: the documents are SQLite files, and a copy taken
mid-write is not a backup.
```bash
cd ~/selfhost/grist
docker compose stop
tar -C ~/selfhost/grist -czf ~/selfhost/grist/backups/grist-$(date +%F).tar.gz persist .env compose.yml
docker compose start
ls -lh ~/selfhost/grist/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds.
That archive sits on the same disk as the data, which is not a backup, 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`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`; confirm the destination exists before
copying. Assert: the user confirms the filename is listed there. If they have neither, say
plainly that this install has no backup.
To restore: `cd ~/selfhost/grist`, `docker compose down`, `rm -rf ~/selfhost/grist/persist`,
untar the archive back into ~/selfhost/grist, then `docker compose up -d` and re-run the three
asserts from step 7. Open a document and check the cell is still there. That is the whole
disaster plan, and it is worth doing once today while the only thing at risk is a test number.
## 9. Updating later
New versions are listed at https://github.com/gristlabs/grist-core/releases. Take the backup
first, then edit the image line in ~/selfhost/grist/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/grist
docker compose pull
docker compose up -d
docker compose logs --tail 30 grist
```
Grist migrates its own SQLite files on the way up, so watch that log until it settles, then
re-run step 7's asserts before calling the update done.
## 10. What will probably go wrong
I rebooted, opened http://localhost:8101 out of habit, got a connection refused, and spent a
minute convinced the documents were gone. They were not: Docker Desktop had not started with
the session, so nothing was listening on 8101, and `restart: unless-stopped` only acts once
the Docker daemon itself is up. The `.grist` files were sitting in `persist/docs` the whole
time. Turn on Docker Desktop's start-at-login setting, and after any reboot run
`cd ~/selfhost/grist && docker compose up -d` before concluding anything is broken.
## 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 8101 to 0.0.0.0 so a phone or a colleague can reach it. Grist has no password
login of its own, so that one edit turns this into an open database on every network the
machine joins.
- Do not configure OIDC, SAML or forward-auth headers. There is no proxy here to set a header,
and a login system with nothing in front of it is worse than none.
- Do not set `ASSISTANT_API_KEY` or `OPENAI_API_KEY`. The formula assistant is a paid account
somewhere else, and this install asks the user for no accounts at all.compose.local.ymlthe services, pinned · local layout43 lines
# Grist · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# self-managed ....... https://support.getgrist.com/self-managed/
# env var reference .. https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# upstream examples .. https://github.com/gristlabs/grist-core/tree/v1.7.17/docker-compose-examples
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/grist/, which lets one file work on macOS, Linux and Windows.
# ./persist stays a bind mount rather than a named volume, because upstream's
# own documented command mounts a home directory there and because a reader
# should be able to see their .grist files in Finder or Explorer. Nothing here
# is reachable from another device, so there is no proxy to authenticate at and
# no forward-auth header: GRIST_DEFAULT_EMAIL is the single identity Grist
# attributes edits to, which is the mode upstream documents for a machine with
# no sign-in configured. Digest read from Docker Hub on 2026-08-06; the image
# publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
grist:
image: gristlabs/grist-oss:1.7.17@sha256:b87ec1c3b62ca99f872611a9aa71ca33ee5fef9f40e0921e0beed878e5083473
container_name: grist
restart: unless-stopped
environment:
# Read from ./.env, which is mode 600 and holds the one generated secret.
GRIST_SESSION_SECRET: ${GRIST_SESSION_SECRET}
# The address edits are attributed to. Upstream's own default, kept
# because nothing off this computer can reach the port below.
GRIST_DEFAULT_EMAIL: you@example.com
APP_HOME_URL: http://localhost:8101
# One team site, so no /o/<team> prefix turns up in any URL.
GRIST_SINGLE_ORG: grist
# Skip the first-run Quick setup gate, which would otherwise ask for a
# boot key pasted out of the container log.
GRIST_IN_SERVICE: "true"
volumes:
# The image chowns everything under /persist to its own user on start,
# then drops out of root, so this directory is left alone after step 3.
- ./persist:/persist
ports:
# Loopback only: no other device on the wifi can reach 8101.
- "127.0.0.1:8101:8484"agent-readable mirror: /self-host/airtable.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned48 lines
# Grist · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# self-managed ....... https://support.getgrist.com/self-managed/
# forwarded headers .. https://support.getgrist.com/install/forwarded-headers/
# env var reference .. https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# upstream examples .. https://github.com/gristlabs/grist-core/tree/v1.7.17/docker-compose-examples
#
# One service. Documents are .grist SQLite files under /persist/docs and the
# account table is /persist/home.sqlite3, so there is no database process to run
# and nothing to dump. grist-core ships no username-and-password login of its
# own: the host Caddy checks the credential and passes the address it verified
# in X-Forwarded-User, which is what GRIST_FORWARD_AUTH_HEADER together with
# GRIST_IGNORE_SESSION tells Grist to trust on every request. Tag and digest
# were read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
grist:
image: gristlabs/grist-oss:1.7.17@sha256:b87ec1c3b62ca99f872611a9aa71ca33ee5fef9f40e0921e0beed878e5083473
container_name: grist
restart: unless-stopped
environment:
# These three come from /srv/grist/.env, which is mode 600. Compose reads
# that file for substitution because it sits beside this one.
APP_HOME_URL: ${APP_HOME_URL}
GRIST_DEFAULT_EMAIL: ${GRIST_DEFAULT_EMAIL}
GRIST_SESSION_SECRET: ${GRIST_SESSION_SECRET}
# Trust this header, and only this header, for identity. Caddy overwrites
# it on every proxied request, so a browser cannot put a name in it.
GRIST_FORWARD_AUTH_HEADER: X-Forwarded-User
GRIST_IGNORE_SESSION: "true"
GRIST_FORCE_LOGIN: "true"
# One team site, so no /o/<team> prefix turns up in any URL.
GRIST_SINGLE_ORG: grist
# Skip the first-run Quick setup gate, which would otherwise ask for a
# boot key pasted out of the container log.
GRIST_IN_SERVICE: "true"
# Formulas are Python running on this server. On a public host they run
# inside gvisor rather than directly.
GRIST_SANDBOX_FLAVOR: gvisor
volumes:
# The image chowns everything under /persist to its own user on start,
# then drops out of root, so this directory is left alone after step 2.
- /srv/grist/persist:/persist
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8101.
- "127.0.0.1:8101:8484"Caddyfilethe hostname and TLS41 lines
# Grist · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.getgrist.com/install/forwarded-headers/,
# https://support.getgrist.com/self-managed/ and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. grist-core has no
# username-and-password login of its own, so this block is the login: Caddy
# checks the credential and then tells Grist which address it verified. Needs
# Caddy 2.8 or newer, which is where the directive is spelled basic_auth.
<DOMAIN> {
# Grist ships a large JavaScript bundle, so compression is worth having.
# WebSocket upgrades pass through untouched.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The credential is not in this file, because this file is published. The
# install writes /etc/caddy/grist-auth.conf with one basic_auth block: the
# username, and a bcrypt hash of the password it generated. That file is
# mode 640, owned by root and readable by the caddy group.
import /etc/caddy/grist-auth.conf
# 8101 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:8101 {
# Set, not add. Whatever a browser sent under this name is replaced by
# the username basic_auth has verified, so the header cannot be
# spoofed, which is the one thing this whole arrangement depends on.
header_up X-Forwarded-User {http.auth.user.id}
}
}install.shthe same install, no agent186 lines
#!/usr/bin/env bash
# Grist · 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=grist.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://support.getgrist.com/self-managed/
# https://support.getgrist.com/install/forwarded-headers/
# https://github.com/gristlabs/grist-core/blob/v1.7.17/README.md
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# grist-core ships no username-and-password login of its own. This script puts
# Caddy's basic_auth in front of it and has Caddy pass the address it verified
# in X-Forwarded-User, which is the second of the two forward-auth modes
# upstream documents. Without that, a Grist on a public hostname is an open
# database.
#
# Two secrets are generated here, on this machine: the session key, in
# /srv/grist/.env, and the password on the login box, in /srv/grist/browser-login.
# Both are mode 600 and neither is ever printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/grist}"
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. grist.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address that will administer this Grist installation"
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"
caddy_major_minor="$(caddy version | head -1 | sed -n 's/^v\([0-9]*\.[0-9]*\).*/\1/p')"
case "$caddy_major_minor" in
2.[0-7]) die "caddy $caddy_major_minor is too old; the basic_auth directive needs 2.8 or newer" ;;
esac
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Grist plus its sandbox wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------
#
# persist is left owned by root at 700. The Grist image starts as root, chowns
# everything under /persist to its own unprivileged user and then drops to it,
# so an ownership fix here would be undone on the next start.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/persist"
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 for both: the login value gets typed into a browser
# dialog. Read them later with
# sudo grep GRIST_SESSION_SECRET /srv/grist/.env
# sudo cat /srv/grist/browser-login
#
# The login password is deliberately not in .env: .env is what compose hands to
# the container, and the credential a human types belongs nowhere near the
# application's environment.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
APP_HOME_URL=https://${DOMAIN_HOST}
GRIST_DEFAULT_EMAIL=${ADMIN_EMAIL}
GRIST_SESSION_SECRET=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
if [ ! -f "$APP_DIR/browser-login" ]; then
umask 077
openssl rand -hex 24 > "$APP_DIR/browser-login"
chmod 600 "$APP_DIR/browser-login"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. The login box, then the Caddy site block, both on the host -----------
#
# caddy hash-password reads stdin when no --plaintext is given, which keeps the
# password out of the process list.
umask 077
caddy hash-password < "$APP_DIR/browser-login" > "$APP_DIR/grist-auth.hash"
printf 'basic_auth {\n\t%s %s\n}\n' "$ADMIN_EMAIL" "$(cat "$APP_DIR/grist-auth.hash")" > "$APP_DIR/grist-auth.conf"
umask 022
sudo install -m 640 -o root -g caddy "$APP_DIR/grist-auth.conf" /etc/caddy/grist-auth.conf
rm -f "$APP_DIR/grist-auth.hash" "$APP_DIR/grist-auth.conf"
sudo grep -q basic_auth /etc/caddy/grist-auth.conf || die "/etc/caddy/grist-auth.conf has no basic_auth block"
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-grist"
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 8101 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; 8101 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 -------------------------------------------------------------
docker compose pull
docker compose up -d
echo "==> waiting for http://127.0.0.1:8101/status"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8101/status?db=1' || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/status answered ${code:-nothing}. Check: docker compose logs --tail 40 grist"
curl -sS 'http://127.0.0.1:8101/status?db=1' | grep -q 'is alive' \
|| die "/status answered 200 without 'is alive'. Check: docker compose logs --tail 40 grist"
curl -sS 'http://127.0.0.1:8101/status?db=1' | grep -q 'db ok' \
|| die "/status did not report 'db ok'. Check: docker compose logs --tail 40 grist"
# Upstream's own sandbox preflight, printed by sandbox/run.sh before the server
# starts. A kernel that will not host gvisor makes the container exit here.
docker compose logs grist | grep -q 'gvisor check ok' \
|| die "the gvisor sandbox check did not pass. Check: docker compose logs --tail 40 grist"
# The security assert: nothing reaches Grist without a credential.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated request returned ${unauth}, not 401. Stop and investigate."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/grist-${STAMP}.tar.gz" -C "$APP_DIR" persist .env browser-login compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/grist-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Grist is answering at https://${DOMAIN_HOST}
1. Sign in with ${ADMIN_EMAIL} and the password in $APP_DIR/browser-login.
Read it with
sudo cat $APP_DIR/browser-login
and put it in your password manager. It was not printed here. That
login box is Caddy, not Grist: grist-core has no password login of its
own, which is why this install refuses every request that arrives
without one.
2. The first screen shows "Create empty document". Make one and put a
number in a cell before you trust anything, because that is the part
that uses the WebSocket the proxy has to carry.
3. Browsers do not offer a sign-out for this kind of login. Closing every
window of that browser is the sign-out.
4. First backup written to $APP_DIR/backups. It holds both secrets and the
documents, and it is on the same disk as the data, which is not a
backup. Copy it somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Airtable.
- The authentication is the install. grist-core ships no username-and-password login of its own, so the login box in front of it is Caddy's, checking one credential this install generates. That works and upstream documents it, and it also means there is no sign-out button, because browsers do not offer one for this kind of login: closing every window of the browser is the sign-out.
- You get the spreadsheet-database, not the rest of Airtable. Grids, column types, formulas written in Python, linked records between tables, and access rules down to the row are all here. Automations, Interfaces, the app marketplace and the two-way sync connectors are not, and no setting turns them on.
- Formulas are Python that runs on your server. This install turns on the gvisor sandbox upstream ships, which needs a kernel that will host it, and the container refuses to start rather than run formulas unsandboxed if it will not. That is the correct failure, and it is the one most likely to interrupt your evening.
- One credential is one person. Everything signed in through that login box is the same Grist user, so a second colleague means handing over your password, not adding an account. Real per-person accounts need an identity provider, which is a different install than this one.
- You own the backups, and they need the container stopped. The documents are SQLite files under /srv/grist/persist, and a copy taken while Grist is writing is not a backup.
Where this came from
“It is imperative that this header cannot be spoofed by the user, since Grist will trust whatever is in it.”
- grist-core is released under the Apache License, Version 2.0, which upstream describes as an OSI-approved free software license. source
- Grist expects to know who is modifying a document and runs in a limited anonymous mode until it does, so upstream directs anyone accessing it across the public internet to connect a sign-on service. source
- The second forward-auth mode upstream documents is HTTP Basic Auth at the proxy plus a header set to the logged-in user, with GRIST_IGNORE_SESSION set so the header is checked on every request. source
- Upstream ships a compose example that authenticates Grist with a reverse proxy's basic auth middleware, calls that setup acceptable on the public internet, and notes it gives no way to sign out. source
- Documents are .grist SQLite files under /persist/docs and the account table is /persist/home.sqlite3, so a single-server install runs no separate database process. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Airtable?
Not Airtable itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Grist. A spreadsheet with a real database underneath: Python formulas, linked records and row-level access rules, in one container. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Airtable?
Grist. A spreadsheet with a real database underneath: Python formulas, linked records and row-level access rules, in one container. Gives you the part of Airtable that holds the data, and is honest that it is not the rest. Grids, column types, linked records across tables, formulas written in Python rather than a bespoke expression language, and access rules that reach down to the individual row. It runs from one container with its documents as SQLite files you can copy, which is why it installs in an evening rather than a weekend. What you take on is the login: grist-core ships no username-and-password screen of its own, so the install puts one in front of it, and what you do not get is Automations, Interfaces, the app marketplace or the sync connectors. Grist is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Airtable?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Grist itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Airtable Team, $100/mo — $1,200 a year, 5 seats assumed.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Grist install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Grist on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Grist on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8101, which means this computer and nowhere else, so the colleague you wanted to share a table with cannot open it and neither can your phone. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.