Architecture¶
How Saveroom is put together: the services that run, how the code is organised, and how a change travels from one browser to every other open tab. Read this first if you are going to operate, debug or change Saveroom.
At a glance¶
Saveroom is two applications plus background workers:
- Backend: a Django application, served as ASGI by gunicorn with uvicorn workers. It
owns all state and exposes it through the REST API (
/api/) and a WebSocket endpoint (/ws/). It has no user interface of its own. - Web frontend: a Nuxt (Vue) application. Its Node server renders the first page (SSR); the browser then talks to the backend directly.
- Celery workers: the default worker runs realtime broadcasts and automations. The export worker runs jobs (imports, exports, duplication, snapshots, backups), webhooks, search indexing, notification emails and cleanup. Beat schedules periodic tasks.
- PostgreSQL stores everything, including the tables users create.
- Redis is the Celery broker, the beat schedule store, the Channels (WebSocket) layer and the cache.
- Media storage holds uploaded files. It is a local directory by default, or S3, GCS or Azure Blob storage.
flowchart LR
browser([Browser])
subgraph edge[Reverse proxy]
proxy[Caddy / OpenShift Route / Ingress]
end
subgraph app[Application]
nuxt[web-frontend<br/>Nuxt SSR :3000]
backend[backend<br/>Django ASGI :8000<br/>REST + WebSocket]
worker[celery worker<br/>queues: celery, automation_workflow]
export[export worker<br/>queue: export]
beat[celery beat<br/>RedBeat scheduler]
end
subgraph data[State]
pg[(PostgreSQL<br/>Django tables +<br/>database_table_N)]
redis[(Redis<br/>broker, channels,<br/>cache, schedule)]
media[(Media<br/>volume or S3/GCS/Azure)]
end
browser -->|"/api/*, /ws/*"| proxy
browser -->|"everything else"| proxy
proxy -->|"/api, /ws"| backend
proxy -->|"/"| nuxt
proxy -.->|"/media, /static (Caddy only)"| media
nuxt -->|"SSR API calls<br/>PRIVATE_BACKEND_URL"| backend
backend --> pg
backend --> redis
backend --> media
worker --> pg
worker --> redis
export --> pg
export --> media
beat --> redis
The browser always reaches the backend at PUBLIC_BACKEND_URL (normally the same origin
as the frontend). The Nuxt server reaches it at PRIVATE_BACKEND_URL, usually the
in-cluster address http://backend:8000.
Deployment shapes¶
All deployments run the same processes. What differs is how they are packaged and what does the path routing.
flowchart TB
subgraph aio[All-in-one image: one container, supervisord]
direction LR
a1[caddy] --- a2[webfrontend] --- a3[backend]
a3 --- a4[celeryworker] --- a5[exportworker] --- a6[beatworker]
a7[(embedded PostgreSQL + Redis<br/>optional)]
end
subgraph compose[docker-compose.yaml: one container per service]
direction LR
c1[caddy] --> c2[web-frontend]
c1 --> c3[backend]
c4[celery] & c8[celery-export-worker] & c5[celery-beat]
c6[(db)] & c7[(redis)]
end
subgraph helm[Helm chart: one Deployment per service]
direction LR
h1[Route / Ingress<br/>path demux] --> h2[web-frontend]
h1 --> h3[backend]
h4[celery] & h8[celery-export] & h5[celery-beat]
h6[(Bitnami PostgreSQL / Redis<br/>or external)]
end
| All-in-one image | docker-compose.yaml |
Helm chart | |
|---|---|---|---|
| Guide | Docker | Docker Compose | Helm |
| Packaging | deploy/all-in-one/; supervisord runs every process |
Builds backend and web-frontend from source |
Standalone backend and web-frontend images |
| Router | Embedded Caddy (Caddyfile) |
Caddy container (Caddyfile) |
OpenShift Route or Ingress; backendPaths (/api, /ws, /static) go to the backend |
| Media | Local volume, served by Caddy at /media |
media volume, served by Caddy |
S3 by default, or a PVC |
| PostgreSQL / Redis | Embedded, or external | db and redis containers |
Bundled subcharts, or external |
| Scaling | One container | Per service | Per Deployment; runs under OpenShift restricted-v2 |
Two worker queues
Most background work is bound to the export Celery queue: run_async_job (every
job), webhooks, search indexing, notification emails, backups, scheduled exports and
cleanup tasks (queue="export" in the tasks.py modules and CELERY_TASK_ROUTES in
backend/src/baserow/config/settings/base.py). Every deployment therefore runs a
celery-exportworker next to the default celery-worker: exportworker in the
all-in-one image, celery-export-worker in docker-compose.yaml and the
celery-export Deployment in the Helm chart. With BASEROW_RUN_MINIMAL and
BASEROW_AMOUNT_OF_WORKERS=1 the default worker takes both queues and the export
worker idles.
Repository layout¶
| Path | What it holds |
|---|---|
backend/ |
Django project: src/baserow/ (code), tests/ (mirrors src/), docker/docker-entrypoint.sh (every process starts here), Dockerfile, pyproject.toml / uv.lock |
web-frontend/ |
Nuxt app: modules/ (feature modules), nuxt.config.ts, env-remap.mjs (env to Nuxt runtime config), test/, Dockerfile |
e2e-tests/ |
Playwright end-to-end suite |
deploy/ |
all-in-one/ image, helm/saveroom/ chart, caddy/, branding-example/, plugins/ (plugin boilerplate tooling) |
formula/ |
ANTLR grammar for the formula language |
integrations/ |
Third-party integrations, such as Zapier |
changelog/ |
Changelog entries and generator (just changelog …) |
docs/ |
This site, ADRs (docs/adr/) |
docker-compose.yaml |
Simple production-like stack (see above) |
docker-compose.yml + docker-compose.dev.yml |
Development stack with hot reload, driven by just |
Caddyfile, Caddyfile.dev |
Reverse proxy configuration |
justfile |
Task runner entry point; dispatches to per-area justfiles |
Backend¶
Django apps¶
backend/src/baserow/ is split into a small core and optional apps under contrib/.
Every app plugs its types into shared registries, so the core never imports a contrib
app directly.
flowchart TB
config[config<br/>settings, urls, asgi, celery]
api[api<br/>DRF views for core]
ws[ws<br/>Channels consumer, page types, broadcasts]
core[core<br/>users, workspaces, applications, jobs,<br/>trash, actions, registries, permissions,<br/>backups, snapshots, notifications, sso]
subgraph contrib[contrib: application types]
database[database<br/>tables, fields, rows, views,<br/>formula, webhooks, export, search]
builder[builder<br/>pages, elements, data sources,<br/>workflow actions, domains]
automation[automation<br/>workflows, nodes, triggers]
dashboard[dashboard<br/>widgets, data sources]
integrations[integrations<br/>local_baserow, core: HTTP/SMTP, slack]
end
config --> api & ws & core
api --> core
ws --> core
database & builder & automation & dashboard & integrations -->|register types in| core
builder & automation & dashboard -->|use services from| integrations
integrations -->|local_baserow reads| database
| Package | Responsibility |
|---|---|
config/ |
Settings (settings/base.py), URL root, asgi.py (HTTP plus WebSocket router), celery.py, read-replica DB router |
core/ |
Users, workspaces, the application abstraction, jobs, trash, undo/redo actions, registries, permission managers, backups, snapshots, notifications, OIDC SSO, data destinations |
api/ |
REST endpoints for core concepts, mounted under /api/ |
ws/ |
CoreConsumer at /ws/core/, page types (who subscribes to what), broadcast tasks, presence |
contrib/database/ |
The database application: tables, fields, rows, views, filters, formulas, webhooks, import/export, search |
contrib/builder/ |
The application builder: pages, elements, data sources, workflow actions, published domains |
contrib/automation/ |
Automations: workflows built from trigger and action nodes |
contrib/dashboard/ |
Dashboards: widgets backed by data sources |
contrib/integrations/ |
Service and integration types that builder, automation and dashboard nodes call |
Third-party plugins load as extra Django apps through BASEROW_BACKEND_PLUGIN_NAMES (see
Plugins).
Layers inside a feature¶
Each feature follows the same stack. API views stay thin, and the rest can be called from views, Celery tasks, management commands or tests alike.
flowchart TB
view["API view + serializer<br/>(api/…/views.py)"]
action["Action type<br/>(actions.py): do / undo / redo"]
service["Service<br/>(builder, automation, integrations)"]
handler["Handler<br/>(handler.py): business logic + DB writes"]
perms["Permission check<br/>CoreHandler().check_permissions"]
model[("Models<br/>Django ORM")]
signal["Django signal<br/>(signals.py)"]
wsrecv["ws receivers<br/>broadcast on commit"]
view --> action
view --> service
action --> handler
service --> handler
handler --> perms
handler --> model
handler --> signal
signal --> wsrecv
- Handlers (
*Handler) hold the business logic. Example:RowHandler.update_rowsincontrib/database/rows/handler.py. - Action types wrap handler calls that users can undo. See the undo/redo guide.
- Services add permission checks and dispatch for the builder, automation and integration domains.
- Registries hold pluggable
Instancetypes: field types, view types, application types, element types, permission managers and so on. See the permissions guide and database plugin. - Signals fan out side effects such as realtime broadcasts, webhooks, notifications and search indexing without coupling them to the handler.
Data model¶
erDiagram
USER }o--o{ WORKSPACE : "member of"
WORKSPACE ||--o{ APPLICATION : contains
APPLICATION ||--o| DATABASE : "is a"
APPLICATION ||--o| BUILDER : "is a"
APPLICATION ||--o| AUTOMATION : "is a"
APPLICATION ||--o| DASHBOARD : "is a"
DATABASE ||--o{ TABLE : contains
TABLE ||--o{ FIELD : "has columns"
TABLE ||--o{ VIEW : "has views"
TABLE ||--|| USER_TABLE : "rows stored in database_table_N"
APPLICATION ||--o{ INTEGRATION : "configures"
Each user-created table is a real PostgreSQL table named database_table_<id>, with one
column per field (field_<id>). The backend builds a Django model for it on the fly with
Table.get_model(). See Table persistence.
Async work¶
flowchart LR
req[API request / handler] -->|"JobHandler.create_and_start_job"| redis[(Redis broker)]
beat[celery beat] -->|periodic tasks| redis
redis -->|"queue: celery<br/>automation_workflow"| worker[default worker<br/>realtime broadcasts,<br/>automation workflows]
redis -->|"queue: export"| export[export worker<br/>jobs, webhooks, search index,<br/>emails, backups, cleanup]
worker & export -->|state + progress| pg[(Job rows in PostgreSQL)]
pg -->|"GET /api/jobs/{id}/"| client[web-frontend]
Long operations run as jobs: a database row tracks state and progress, and a Celery task does the work. See the jobs pattern.
Web frontend¶
web-frontend/modules/ mirrors the backend apps. Each module is a Nuxt module that
registers routes, Vuex stores and *Type classes into the client-side registry.
flowchart TB
subgraph modules[web-frontend/modules]
core[core<br/>auth, workspaces, sidebar, jobs,<br/>undo/redo, presence, registry]
database[database<br/>grid/gallery/form views,<br/>field types, row modal]
builder[builder]
automation[automation]
dashboard[dashboard]
integrations[integrations]
end
registry[(Registry<br/>core/registry.js)]
store[(Vuex store)]
client[clientHandler.js<br/>axios + auth + errors]
rt[realTimeHandler.js<br/>WebSocket /ws/core/]
database & builder & automation & dashboard & integrations -->|"plugin.js registers types"| registry
core --> registry
modules --> store
store -->|"services/*.js"| client
rt -->|"realtime.js handlers"| store
client -->|REST| backend[(backend)]
rt <-->|events| backend
| Piece | Where |
|---|---|
| Module wiring | modules/<name>/module.js, plugin.js, routes.js |
| Pluggable types | fieldTypes.js, viewTypes.js, applicationTypes.js, … registered in plugin.js |
| State | modules/<name>/store/ (Vuex) |
| HTTP | modules/core/plugins/clientHandler.js plus modules/*/services/ wrappers |
| Realtime | modules/core/plugins/realTimeHandler.js plus modules/*/realtime.js event handlers |
| Runtime config | env-remap.mjs maps PUBLIC_BACKEND_URL and similar variables to Nuxt runtime config |
| Branding | Loaded at runtime from BASEROW_BRANDING_DIR (Branding) |
Request flow: editing a cell¶
This flow shows most of the architecture in one pass: REST, an undoable action, the handler, a signal, Celery and the Channels layer.
sequenceDiagram
autonumber
participant A as Browser A
participant P as Proxy
participant B as Backend (ASGI)
participant DB as PostgreSQL
participant Q as Redis
participant W as Celery worker
participant C as Browser B
A->>P: PATCH /api/database/rows/table/{id}/{row_id}/
P->>B: forward
B->>B: RowView.patch → UpdateRowsActionType.do
B->>DB: RowHandler.update_rows → UPDATE database_table_{id}
B->>B: rows_updated signal → broadcast on commit
B-->>A: 200 updated row
B->>Q: broadcast_to_channel_group.delay(...)
Q->>W: task
W->>Q: channel_layer.group_send("table-{id}")
Q->>B: deliver to CoreConsumer sockets
B-->>C: rows_updated event over /ws/core/
C->>C: database/realtime.js updates Vuex store
The sender's web_socket_id is excluded from the broadcast, so browser A doesn't apply
its own change twice. For more, see WebSockets and
Realtime presence.
Where to go next¶
- Run it: Docker Compose or Helm.
- Change it: Development environment.
- Extend it without forking: Plugins.