Self-hosting

Two minutes

OpenCalc runs on your own servers. Nothing phones home, there is no licence check, and no document leaves your network.

# from a clone of the repository
cp .env.example .env
# change OPENCALC_SHARED_SECRET — the file ships with a value the server refuses
docker compose up
open http://localhost:8080

The images are published, so this pulls rather than builds: casualoffice/calc is the collaboration server, casualoffice/calc-host serves the editor and keeps the documents, and casualoffice/wopi is the adapter for a host that speaks WOPI. Each is multi-arch, and each ships an SBOM and a build-provenance attestation naming the repository, workflow and commit it was built from — so what you pull can be traced to what is here. Add --build to build from your own checkout instead.

New spreadsheet → Share → send the link to somebody else. You are both editing the same sheet. Download takes away an .xlsx containing what you both typed.

Change the secret first. .env.example is a public file, so the value in it is not a secret and the collaboration server rejects it by name at startup rather than booting into a deployment anybody can mint tokens for. openssl rand -hex 32 is enough.

What the pieces are

ContainerWhat it doesWho supplies it in the end
hostDocuments, identity, tokens, savingYou. This is your product
collabOrdering, presence, the WebSocketOpenCalc
proxyPuts the page and the socket on one originYours, or use ours
wopiMakes OpenCalc appear inside Nextcloud, SharePoint and friendsOpenCalc, optional

That split is the integration boundary, not an artefact of the demo. The collaboration server holds no per-document state and deliberately cannot mint tokens. Per join, it is told by a party that already knows: where the file lives, where the finished bytes go, who is joining and what they may do.

server/casual-calc-host is that party, written small enough to read in one sitting. When you integrate, you replace it — the collaboration server and the editor stay as they are.

Standalone or cluster

Two stacks, both docker compose up.

# one collaboration node — what most deployments should run
docker compose up                                     # :8080

# two nodes behind a proxy, coordinating through Redis
docker compose -f docker-compose.cluster.yml up       # :8090

The cluster buys horizontal capacity, not correctness. Standalone is a first-class mode and not a degraded one, which is a deliberate architectural position rather than a limitation we have not got round to.

The host contract

Four endpoints and a signature is the whole of it.

GET  /api/documents/{id}/content     # what the collaboration server fetches
POST /api/documents/{id}/callback    # where the finished bytes come back
GET  /api/documents/{id}/session     # a minted token, for the browser
GET  /api/documents/{id}/download    # what a user takes away

Implement those four against your own storage and identity, and you have replaced the reference host. Nothing else in the system needs to know.

The two addresses that get confused

This is the most common first failure, and it produces an editor that says connecting forever with nothing in any log.

VariableWho dials itWhat to set
OPENCALC_COLLAB_WS The browser Usually leave it unset. The host derives it from the request the browser just made — same origin, /collab, and wss:// when X-Forwarded-Proto says the page arrived over TLS. Set it only when collaboration lives on a hostname of its own.
OPENCALC_HOST_INTERNAL The collaboration server What that server calls your host. On a compose network, a service name — http://host:8080. Point it at localhost and the server fetches itself.

They look interchangeable and are on different networks. If you do set OPENCALC_COLLAB_WS, the /collab path is added when you leave it off, because the server serves that path and nothing else — an endpoint without it is an address nothing answers.

Tokens and identity

Your host mints a short-lived JWT per join; the collaboration server verifies it and cannot produce one. The claims are the whole contract: who the user is, which document, where its bytes live, where to post them back, and what this person may do.

VariableMeaning
OPENCALC_SHARED_SECRETHS256, the same string on both sides. Fine for a demo. It means the collaboration server can mint tokens as well as check them, and it says so loudly at boot.
OPENCALC_JWKS_URLWhat a real deployment should use. The server fetches your public keys and can verify a token without being able to forge one. Keys are re-read periodically, and a token naming an unknown kid prompts one refresh — throttled, so an invented kid cannot make the server hammer your endpoint.
OPENCALC_ISSUERSMulti-tenant. issuer=https://…/jwks.json, comma-separated. Each tenant gets its own key set, and a token is checked against the keys of the issuer it names. This is the only configuration in which the issuer is a boundary: with one shared key set, any tenant holding a trusted key can mint a token naming any other tenant.
OPENCALC_ISSUERPins the expected iss for a single-tenant deployment. Refuses a token the same signer minted for somebody else. It does not make the deployment multi-tenant — one key set cannot.
OPENCALC_AUDIENCEThe audience a token must name. Any string, the same on both sides.
OPENCALC_TOKEN_LEEWAY_SECSClock skew allowed when checking expiry. Defaults to 60.

Access is view, comment or edit, and it is enforced at the operation, on the server. A client that hides its toolbar and a server that enforces nothing look identical until somebody edits anyway.

Host variables

VariableDefaultWhat it is
OPENCALC_HOST_BIND0.0.0.0:8080Where the host listens.
OPENCALC_STOREDirectory for documents and their metadata. A volume in compose.
OPENCALC_SHARED_SECRETSigns the tokens it mints. See above.
OPENCALC_AUDIENCEopencalc-demoAudience claim.
OPENCALC_HOST_INTERNALWhat the collaboration server calls this host.
OPENCALC_COLLAB_WSderivedWhat the browser dials. Usually unset.
OPENCALC_MAX_UPLOAD_BYTES67108864 (64 MB)Largest workbook accepted. The web framework's own default is 2 MB, which is smaller than many real spreadsheets.
OPENCALC_ADMIN_TOKENunsetSwitches /admin on. Unset means there is no admin page at all.
<NAME>_FILEAny secret can be read from a file instead: OPENCALC_SHARED_SECRET_FILE, OPENCALC_ADMIN_TOKEN_FILE, OPENCALC_REDIS_URL_FILE. Use it. An environment variable is readable in docker inspect, in /proc/1/environ, and in whatever your process manager logs. Setting both forms is refused, not ranked — a silent precedence rule is how a deployment that believes it moved to files keeps running on the variable it forgot to delete.
OPENCALC_FONT_DIRunsetExtra faces for the headless renderer. See Fonts.
OPENCALC_EDITOR_DIR/editorWhere the editor bundle is served from.

Collaboration variables

The ones an operator actually changes:

VariableWhat it is
OPENCALC_BINDPublic listener.
OPENCALC_INTERNAL_BINDA second listener for metrics and health, so they need not be exposed with the socket.
OPENCALC_ALLOWED_HOSTSHosts this server will fetch documents from and post results to. A token names a URL, so this is what stops a forged one pointing it anywhere.
OPENCALC_ALLOW_PLAIN_CALLBACKSPermits plain http for those calls. Local development only; off by default.
OPENCALC_MAX_DOCUMENTS, OPENCALC_MAX_PARTICIPANTSCapacity per node.
OPENCALC_MAX_DOCUMENT_BYTES, OPENCALC_MAX_MESSAGE_BYTESSize bounds on what it will hold and accept.
OPENCALC_MAX_PENDING_CONNECTIONS, OPENCALC_JOIN_TIMEOUT_MSA WebSocket upgrade cannot require a token — the token arrives in the first frame — so these bound how many sockets may be open and silent. Separate from MAX_PARTICIPANTS, which counts people in a document.
OPENCALC_IDLE_EVICTION_MSHow long a document with nobody in it stays resident.
OPENCALC_CLIENT_PING_MS, OPENCALC_CLIENT_IDLE_MSHow the server decides a participant is gone. See the proxy note below.
OPENCALC_DRAIN_TIMEOUT_MS, OPENCALC_DRAIN_DEADLINE_MS, OPENCALC_DRAIN_CONCURRENCYShutdown: how long it will spend saving open documents before it stops.
OPENCALC_TLS, OPENCALC_TLS_CERT, OPENCALC_INTERNAL_TLSTerminate TLS in the process rather than at a proxy.
OPENCALC_TRUSTED_PROXIES, OPENCALC_TRUST_ANY_PROXYWhose X-Forwarded-* headers to believe. Believing anybody's is how a client picks its own address.
OPENCALC_REDIS_URL, OPENCALC_REDIS_NAMESPACE, OPENCALC_NODE_ID, OPENCALC_ADVERTISE, OPENCALC_LEASE_MSCluster only: coordination, identity and how long a node's lease on a document lives. For a replicated coordinator point the URL at the sentinels rather than a box — redis+sentinel://h1:26379,h2:26379,h3:26379/opencalc, or rediss+sentinel://… over TLS. The address is then re-asked on every failure instead of fixed at startup, so a promotion is found — including the case where the old primary is demoted rather than killed and would otherwise refuse every write forever on a socket that looks perfectly healthy.
OPENCALC_PUBLIC_URLCluster only, and optional. Where a browser reaches this node — wss://node-2.example/collab — which is deliberately not OPENCALC_ADVERTISE: that one is a service name on the cluster network, the address a client cannot use. Set it and a node at its document cap answers a refusal with the least-loaded peer that has room, instead of only saying no. Leave it unset behind a single load balancer, where a redirect would come straight back through the balancer to an arbitrary node; placement then does nothing, which is the honest outcome rather than a guess.
OPENCALC_REDIS_MIN_REPLICASHow many in-sync replicas the coordinator must be configured to require. Redis replication is asynchronous, so a failover can drop an append the primary already said was saved; min-replicas-to-write turns that window into a refusal instead — clients are told NotSaving and /readyz answers 503. Set this to the same number and the node checks the setting is really there, at startup and again after every failover: it is per server, and the usual mistake is configuring the original primary and nothing else, which passes a startup check and fails exactly when it matters. The price, stated rather than hidden: a failover leaving no replica in sync becomes a visible write outage. Unset means unchecked, which is right for a single-box coordinator. (ADR-020)
OPENCALC_REDIS_CA, OPENCALC_REDIS_CLIENT_CERT, OPENCALC_REDIS_CLIENT_KEYEncrypting the coordinator link. Use a rediss:// URL, and OPENCALC_REDIS_CA for a private CA — which is the normal case, since an internal Redis has no public certificate. The two client settings are mutual TLS and are both-or-neither. Certificates configured against a plain redis:// URL are refused at startup: that shape reads as encrypted and is not.

Everything is read once at startup and checked before the listener opens. A node that starts and is subtly wrong costs more than one that refuses to start and says why.

WOPI variables

VariableWhat it is
OPENCALC_WOPI_ALLOWED_HOSTSRequired, no default, and the process will not start without it. A WOPISrc arrives in a query string, so it is chosen by whoever wrote the link the user clicked. Without a list, the service fetches whatever that link says — a server-side request forgery with a friendly interface.
OPENCALC_WOPI_PUBLIC_URLWhere a browser reaches the adapter. It goes into the discovery document, so it is the address the file store sends people to. Behind a proxy it is never the bind address.
OPENCALC_WOPI_INTERNAL_URLWhere the collaboration server reaches it, which is not the same thing.
OPENCALC_WOPI_SESSION_TTL_MSHow long an editing session — and the WOPI lock it holds — lives. Default 8 hours. A lock left behind makes somebody's file read-only in their own file store.
OPENCALC_WOPI_MAX_SESSIONSDefault 500.
OPENCALC_WOPI_PROOF_KEYA PKCS#8 private key, as a file path so the secret arrives on a mounted file rather than an environment variable. Signs every outgoing request so a host can prove it came from this editor. Optional; a key that will not load is fatal at boot rather than a warning.
OPENCALC_WOPI_ALLOW_PLAINPermits a plain http WOPISrc. Local development only.
OPENCALC_BRAND_NAME, OPENCALC_BRAND_FAVICON_URL, OPENCALC_BRAND_ACCENTWhite-labelling: the entry in the file store's editor list, the browser tab and the toolbar.

Changing things while it runs

/admin is off entirely unless OPENCALC_ADMIN_TOKEN is set. There is no default password — a demo that ships with a known one is a demo somebody exposes to the internet once and then explains.

It changes what can safely move under a live server: the endpoint new sessions are given, whether uploads are offered, the banner. Settings are written beside the documents, so they survive a restart.

Fonts

Only the headless renderer needs these — thumbnails and PNG export. The editor never does; the browser draws cells with its own faces.

Latin is built in. Drop a .ttf, .otf or .ttc into OPENCALC_FONT_DIR for any other script and it is registered at boot. Empty is fine and is the normal case.

Behind a reverse proxy

Working configurations for nginx and Caddy are in deploy/. Two things are not obvious, and each produces the same symptom — an editor that reconnects forever:

  1. The WebSocket upgrade headers

    Without Upgrade and Connection, the handshake is answered with a plain 200.

  2. A read timeout longer than the server's client ping

    A co-editing socket is idle whenever nobody is typing, and nginx closes idle proxied connections after sixty seconds by default. The server should decide when a participant has gone; the proxy deciding for it looks like a network fault to everybody in the document.

Serve everything from one origin — editor, API and WebSocket. A share link is then a single URL, with no CORS to configure and no second certificate. The standalone stack does this itself: docker compose up starts an nginx in front of both services, so the page and the socket share a hostname.

TLS

Terminate at your proxy, which is what most deployments already do, and forward X-Forwarded-Proto so the host hands out wss:// rather than ws://. An https page cannot open a ws:// socket at all, and the browser's refusal is the only thing that reports it.

If you would rather the collaboration server terminate TLS itself, point OPENCALC_TLS_CERT at a certificate and key. Set OPENCALC_TRUSTED_PROXIES to the proxies whose forwarding headers you are willing to believe — believing anybody's lets a client choose its own apparent address.

Health and metrics

EndpointAnswers
/healthzIs the process alive.
/readyzIs it ready to take work.
/statsDocuments and participants, as JSON — "is it working now", for a person.
/metricsPrometheus text — "has it been working", for a machine.

The counters worth putting on a dashboard:

MetricAnswers
opencalc_saves_accepted_total / _failed_totalAre documents getting back to the host?
opencalc_save_duration_milliseconds_totalDivided by the counts, how slow is your callback?
opencalc_fetches_ok_total / _failed_totalCan the server reach your host at all?
opencalc_documents_unreadable_totalIs the host answering 200 with something that is not a workbook?
opencalc_revisions_totalIs anything being edited? Counts operations, so it moves in step with the revision number rather than with submissions.
opencalc_connections_refused_pending_totalIs the node full while still answering /healthz?
opencalc_joins_refused_capacity_totalAre arrivals being turned away by a cap?
opencalc_joins_redirected_totalCapacity refusals that named a node with room. A subset of the refusals above — the client is still refused. The gap between the two is the number worth alerting on: refusals naming nowhere mean the cluster is full, or that nobody set OPENCALC_PUBLIC_URL and placement is silently doing nothing.
opencalc_slow_consumers_totalAre clients being dropped for lagging? Survivable, but silent.
opencalc_appends_refused_totalCluster: is a fenced or stale leader still trying to write?
opencalc_documents / opencalc_participantsCurrent load, as gauges.

Alert on saves_failed_total increasing first. It is the only counter that means work is at risk, rather than that something is merely busy.

Storage and backup

The reference host keeps each document and its metadata as files under OPENCALC_STORE, which compose mounts as a named volume. Back that directory up and you have backed up every document.

When you replace the reference host with your own, this stops being ours: storage, retention and backup become whatever your product already does. That is the point of the boundary.

The reference host keeps the previous contents of a document each time it is saved, bounded by OPENCALC_MAX_VERSIONS (default 20). They are listed, downloadable and restorable:

GET  /api/documents/{id}/versions             # newest first
GET  /api/documents/{id}/versions/{at}        # look at one
POST /api/documents/{id}/versions/{at}/restore

Restoring keeps the current document as a version first, so a restore is itself undoable — the one button whose purpose is undoing a mistake should not be the only one that cannot be undone.

A restore lands on the stored file, not on open sessions. Anyone with the document open is still editing what was there before, and their next save overwrites the restore. Everyone should reload. Making a restore reach live sessions means the collaboration server evicting the document, which is a change to a boundary that is deliberate — it holds no per-document state.

When you replace the reference host with your own, this stops being ours: retention and restore become whatever your product already does, and the callback is where each finished version arrives.

Sizing and performance

Measured, not modelled — and labelled where the two differ.

Two machines, and they are not the same one. Memory comes from the Linux CI runner, which is the only machine here with /proc. The timings come from an Apple M-series laptop in a release build. Treat the timings as shape and headroom rather than as a spec for your hardware.

What the engine costs, per operation

OperationMeasuredBudgetHeadroom
Cell edit, kept dependency graph0.13 – 0.25 µs
Range edit, kept graph1.5 – 2.0 µs
Visible-window repaint (1600×900)0.09 ms≤ 8 ms engine-side~86×
Recalc, cold first edit2.2 ms< 50 ms~23×
Recalc, adversarial chain + fan-out3.1 ms< 50 ms (T3 cap)~16×
Snapshot round trip, 10 000 cells2.9 msnot interactive
Open a small .xlsx package9 µs

The adversarial recalc case is the one that matters: a deep dependency chain and a wide fan-out, which is the shape a worst case takes. At 3.1 ms against a 50 ms cap, recalculation is not close to being the limit.

What a sheet costs in memory

83.64 bytes per cell, measured as the peak resident-set delta for a 50 000-cell build on the CI runner.

SheetMemory
1 000 cells~84 KB
10 000 cells~0.8 MB
100 000 cells~8.4 MB
1 000 000 cells~84 MB

That figure is numeric cells. Text interns, so repeated strings are cheap — but a formula holds an AST per cell, and a formula-heavy sheet costs materially more. How much more is not yet measured. Size from your own documents if they are formula-dense.

What a live document costs on the server

The per-cell figure above is the workbook. A document that is open costs more than its cells: an operation log, participant state, and the broadcast path to everyone in it. That overhead is now measured — 190 KB per open document at three editors — from a sweep against a Linux container, with the node reporting its own resident set.

DocumentsEditorsEditsp50p99Node RSSPer document
idle06.6 MB
10301 200 / 1 20027 µs1.5 ms11.6 MB514 KB
25753 000 / 3 00016 µs2.8 ms12.4 MB235 KB
501506 000 / 6 00018 µs1.1 ms15.9 MB188 KB

Every edit was acknowledged at every step — 10 200 of 10 200. The per-document column falls as documents are added because it still carries a share of the 6.6 MB a node costs when empty; the marginal cost between the 25- and 50-document runs is 143 KB. Round to 190 KB and it is a safe number to size with.

This corrects the sizing advice that was on this page. Sizing from cells alone is fine for large sheets and badly wrong for small ones: at 1 000 cells the overhead is 70% of the total, so a deployment of many small documents was under-sized by 3.3×. At 100 000 cells it is 2% and does not matter. If your documents are small, the document count is what you are sizing for, not the cell count.

Latency is a Docker Desktop VM on a laptop, so treat p99 as an upper bound rather than a specification — it did not degrade between 50 and 150 concurrent editors, which is the part that matters. RSS is the figure to trust: it is read from the node's own opencalc_resident_bytes gauge inside the container.

Nodes, sheets and people

At three concurrent editors per sheet, and with the default OPENCALC_MAX_DOCUMENTS of 1 000 per node:

RAM for documents1k-cell sheets10k-cell sheets100k-cell sheets1M-cell sheets
4 GB~15 400 · 46 200 users~4 150 · 12 400 users~500 · 1 500 users~51 · 153 users
8 GB~30 800 · 92 500 users~8 300 · 24 900 users~1 000 · 3 000 users~102 · 306 users
16 GB~61 600 · 185 000 users~16 600 · 49 800 users~2 000 · 6 000 users~204 · 612 users

Nothing here is a configured ceiling. There is no cap on documents or on people; a node admits work until its memory is short, and the figures above are what that works out to. It scales by adding nodes — a document is owned by exactly one node at a time, so nodes do not contend for it.

Admission stops at 85% of the node's limit, not at 100%. Reaching the cgroup ceiling is an OOM kill, and that takes every document on the node — including the unsaved work of everybody in them. Refusing one new document costs one person a click. The limit is read from the container's cgroup, not from the host: a 2 GB container on a 64 GB machine is a 2 GB node. OPENCALC_MEMORY_HIGH_WATER_PERCENT moves the mark between 50 and 95.

A rule of thumb

If you want one line to size a node from, this is it:

# RAM for documents, in GB
GB  =  users ÷ 3  ×  (average cells per sheet × 84 bytes  +  190 KB)  ×  1.2

# or, backwards — how many people a node holds
users  =  GB × 3 ÷ ((average cells × 84 bytes + 190 KB) × 1.2)

÷ 3 is the editors-per-sheet assumption — change it to yours. 84 bytes is the measured per-cell cost and 190 KB the measured per-document one; both are figures this project ran for, not estimates. ×1.2 is what is left of the headroom once the overhead is a term rather than a guess — it covers the 85% admission mark and nothing else. The previous form of this line multiplied cells alone by a generous ×1.4 to stand in for an unmeasured overhead, and that stand-in was too small by 3× for small documents. Add the 190 KB; do not fold it into a fudge.

Worked ranges

At three editors a sheet, sized with the headroom above. Pick the row that matches your documents, not your ambitions — sheet size moves this far more than user count does.

Concurrent users Sheets Small (10k cells) Typical (50k cells) Large (250k cells)
3010< 0.1 GB~0.06 GB~0.3 GB
15050~0.06 GB~0.3 GB~1.5 GB
300100~0.12 GB~0.6 GB~3 GB
1 500500~0.6 GB~3 GB~15 GB
3 0001 000~1.2 GB~6 GB~30 GB
15 0005 000~6 GB~30 GB~147 GB · split across nodes

Start at 2 GB and watch. Nearly every deployment below a few hundred people fits there, and the two gauges on /metricsopencalc_documents and opencalc_participants — tell you what is actually resident rather than what a table predicted. Size from those after a week; the table is for the first day.

Add nodes rather than growing one past about 16 GB. A document is owned by exactly one node, so capacity adds up cleanly, and one large node is a single thing to lose.

The engine is not what runs out first

Worth stating plainly, because it is where sizing intuition usually goes wrong. Three thousand editors each committing two edits a second is 6 000 edits a second; at 0.25 µs of engine time each, that is 1.5 ms of CPU per wall second — about a sixth of one percent of a single core.

What you will actually run out of is memory, and then socket and broadcast work. Provision for resident documents and connection count, not for calculation.

What is not measured, and so is not claimed. There is no server-side load harness: the numbers above are the engine measured directly, not a node under sustained multi-user load. Per-document overhead in the collaboration server — the operation log, participant state — is on top of the per-cell figure and is unmeasured. Nothing here is a substitute for watching opencalc_documents and opencalc_participants on your own traffic.

Nextcloud, SharePoint, Moodle, ownCloud, Alfresco

The WOPI adapter is what makes OpenCalc installable into a file store: an administrator pastes one URL into their settings and OpenCalc appears in the editor list.

docker compose --profile wopi up -d
# then give your file store:
https://your-host/hosting/discovery

A file opened that way is fetched from the store, edited — with everybody who opens it in the same session — and written back under its own name, in the format it arrived in. .xlsx, .csv, .tsv and .psv round-trip today. .ods does not yet, and is deliberately absent from the advertised list rather than accepted and silently converted.

Failures worth knowing about first

SymptomAlmost always
The editor says connecting foreverOPENCALC_HOST_INTERNAL pointing at localhost, so the collaboration server fetches itself. Or a proxy without the upgrade headers.
It works on your machine and nowhere elseOPENCALC_COLLAB_WS set to a loopback address. Unset it and let the host derive it.
Everybody drops out after about a minuteThe proxy's read timeout is shorter than the server's client ping, so the proxy is deciding who has left.
An https page never connectsX-Forwarded-Proto is not reaching the host, so it is handing out ws://, which the browser will not open from an https page.
The server refuses to start and names the secretWorking as intended — .env.example's value is public and is rejected by name.
The WOPI container will not startOPENCALC_WOPI_ALLOWED_HOSTS is empty. It has no default on purpose.
Uploads over a couple of megabytes failOPENCALC_MAX_UPLOAD_BYTES, and your proxy's own body limit, which is a separate number.
Non-Latin text is boxes in a PNG exportNo face for that script in OPENCALC_FONT_DIR. The editor is unaffected.

What is not done yet

These are tracked in the open, not hidden: the execution tracker lists every one with its priority.

The operator's reference is docs/65, the WOPI handshake is in docs/74, and every variable's authoritative default is the main.rs of the binary that reads it — the only copy that cannot go stale.