Why Grist instead of Google Sheets or Airtable
Google Sheets is a spreadsheet: it stores data flat, calculations are limited to sheet functions, and relations between sheets rely on fragile VLOOKUPs. Airtable solves this with relational bases but at $20 per user per month, the bill adds up fast for an agency or technical team.
Grist combines both worlds: a familiar spreadsheet interface over a real relational database. Each document contains tables with typed columns (text, numeric, date, reference, attachment), explicit relations between tables via reference columns, and Python formulas that run server-side — not in the browser. On your VPS, Grist costs $0 extra per month.
What Grist does that Google Sheets cannot
- Relations between tables: a reference column in
Orderspoints toCustomers— not a breakable VLOOKUP, a real foreign key with autocomplete. - Server-side Python formulas:
import datetime,import re,import uuid— and any pure-Python library. Formulas execute in a server sandbox, not the browser. - Automatic REST API: every document exposes
/api/docs/<id>/tables/<table>/recordswithout configuration. Integrate Grist with n8n, Make or any HTTP client. - Multiple views on the same data: grid, card, chart, calendar, custom widget — each view is filtered and configured independently without duplicating data.
- Per-row, per-column access rules: restrict read or write access to a row based on user attributes — granular enough for multi-tenant documents.
Requirements and Docker architecture
A VPS with at least 1 GB of RAM and Docker installed is sufficient to get started. Grist deploys as a single gristlabs/grist container bundling Node.js (web server), Python (formula sandbox) and SQLite (document storage). A named volume /persist stores .grist files — one file per document. No external service required: no PostgreSQL, no Redis, no Celery.
The default port is 8484. Verify it is reachable from localhost before configuring the reverse proxy (curl -sI http://localhost:8484/).
Full Docker Compose and deployment
Create the directory and docker-compose.yml
SSH into your VPS and create the stack directory:
mkdir -p /opt/stacks/grist && cd /opt/stacks/gristCreate the following
docker-compose.yml:services: grist: image: gristlabs/grist:1.7.20 container_name: grist restart: unless-stopped ports: - "127.0.0.1:8484:8484" volumes: - grist_data:/persist environment: GRIST_SESSION_SECRET: "REPLACE_WITH_openssl_rand_hex_32" APP_HOME_URL: "https://grist.yourdomain.com" GRIST_DEFAULT_EMAIL: "[email protected]" GRIST_SANDBOX_FLAVOR: "gvisor" volumes: grist_data:Generate the session secret before starting:
openssl rand -hex 32. Never reuse this value across instances.Start and verify
docker compose up -d docker compose logs -f gristWait for the
Grist server readymessage. Check the HTTP status:curl -sI http://localhost:8484/You should get
HTTP/1.1 200 OKor a302redirect. If the container keeps restarting, see the Troubleshooting section below.Configure nginx as HTTPS reverse proxy
Install nginx and certbot on the VPS, then create
/etc/nginx/sites-available/grist.conf:server { listen 80; server_name grist.yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name grist.yourdomain.com; ssl_certificate /etc/letsencrypt/live/grist.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/grist.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; location / { proxy_pass http://127.0.0.1:8484; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; } }Obtain the certificate and enable the vhost:
certbot certonly --nginx -d grist.yourdomain.com ln -s /etc/nginx/sites-available/grist.conf /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginxThen update
APP_HOME_URLin the Compose file and restart:docker compose up -d.
Advanced Grist formulas: VLOOKUP and cross-table references
Grist replaces fragile Google Sheets VLOOKUPs with reference columns and typed lookup functions. In an Orders table with a Customer column of type Reference → Customers, accessing the customer name is simply $Customer.Name — no VLOOKUP needed.
For aggregations, Grist provides lookupRecords() and lookupOne():
# In the Customers table — total order amount for a customer
SUM(Orders.lookupRecords(Customer=$id).Amount)
# In the Orders table — retrieve the customer's postal code
Customers.lookupOne(Name=$Customer.Name).PostalCode
# Progressive reduction using standard Python
import datetime
delay = (datetime.date.today() - $OrderDate).days
$Amount * (1 - 0.02 * delay // 30)Python formulas run inside a gVisor sandbox (GRIST_SANDBOX_FLAVOR=gvisor): the Python process has no access to the filesystem or network, isolating computations even in multi-tenant contexts.
SSO authentication with OIDC (optional)
By default, GRIST_DEFAULT_EMAIL automatically logs in a single user without a password — sufficient for solo use or a trusted team on a private network. For multi-user deployments with real authentication, Grist supports OpenID Connect (OIDC) with Authentik, Keycloak, Google Workspace or any compatible provider.
Add these variables to the Compose file:
environment:
GRIST_OIDC_IDP_ISSUER: "https://auth.yourdomain.com/application/o/grist/"
GRIST_OIDC_IDP_CLIENT_ID: "your-client-id"
GRIST_OIDC_IDP_CLIENT_SECRET: "your-client-secret"
GRIST_OIDC_IDP_SCOPES: "openid email profile"
GRIST_OIDC_SP_HOST: "https://grist.yourdomain.com"Remove GRIST_DEFAULT_EMAIL once OIDC is configured — the two variables are incompatible. The first user to log in via OIDC becomes the site administrator.
Backups: Docker volumes and export
Grist documents live in the Docker volume grist_data, under /persist/docs/. Two complementary strategies:
Volume snapshot (full backup, recommended): mount /persist/docs/ in a Backrest job (available in the ServOrbit catalogue). Backrest snapshots the folder via restic and pushes encrypted increments to S3, Backblaze B2 or SFTP — without stopping Grist.
# Emergency manual backup
docker run --rm \
-v grist_data:/source:ro \
-v /backups/grist:/dest \
alpine tar czf /dest/grist-$(date +%Y%m%d).tar.gz -C /source .Per-document export: in Grist, go to Document → Export → .grist (native SQLite format) or CSV/XLSX per table. The .grist export is sufficient to migrate a single document to another instance.
Grist also keeps a per-document snapshot history (Document → History) — useful for restoring a previous version without leaving the interface, up to the configured retention limit.
Troubleshooting
Container keeps restarting: check the logs (docker compose logs grist). The most common cause is an empty or too-short GRIST_SESSION_SECRET — it must be at least 32 hexadecimal characters. Also check volume permissions: docker exec grist ls -la /persist.
CORS errors on the API: if you call /api/docs/… from a different domain, add GRIST_ALLOWED_HOSTS=grist.yourdomain.com and make sure APP_HOME_URL matches the public URL exactly (with https://, no trailing slash).
Slowness on large datasets: Grist re-evaluates all formulas on every change. On tables with more than 100,000 rows, limit lookupRecords() to indexed columns and avoid Python formulas with heavy imports in trigger columns. The GRIST_MAX_ROWS_PER_TABLE variable (unbounded by default) allows setting a preventive limit.
Access issues after domain change: update APP_HOME_URL, clear browser cookies and restart the container. Active sessions use the old URL and become invalid.
Grist vs NocoDB vs Baserow: choosing your open-source spreadsheet
Scroll the table
| Criterion | Grist | NocoDB | Baserow |
|---|---|---|---|
| Storage | SQLite per document (.grist file) | PostgreSQL or MySQL (shared DB) | PostgreSQL (shared DB) |
| Formulas | Server-side Python (sandbox) | No native formulas | Limited formulas, client-side JS |
| REST API | Automatic per document | Automatic per base | Automatic |
| SSO auth | Native OIDC | LDAP, OIDC (Enterprise) | SSO paid (plans > Pro) |
| Backups | One .grist file per document | PostgreSQL dump | PostgreSQL dump |
| Ideal use case | Data modelling, complex formulas | Team-oriented Airtable replacement | No-code interface for business teams |
| Resources (startup) | 1 GB RAM, Docker | 2 GB RAM, Docker + PostgreSQL | 2 GB RAM, Docker + PostgreSQL |
To update Grist, change the image tag in the Compose file (gristlabs/grist:1.7.20 → next version), stop the container (docker compose down), then restart (docker compose up -d). The grist_data volume persists across versions. Check the release notes at github.com/gristlabs/grist-core before each update — some versions introduce schema migrations that are not reversible.