Deployment Guide¶
Docker Compose (Recommended)¶
The simplest way to run Crystal with persistent storage:
This starts Crystal on port 7540 with a named volume crystal_data for persistence.
To stop:
Data persists in the crystal_data volume across restarts. To destroy data:
Docker¶
Build the Image¶
Run with a Bind Mount¶
Run with a Named Volume¶
docker volume create crystal_data
docker run -d \
--name crystal \
-p 7540:7540 \
-v crystal_data:/data \
crystal
Binary¶
Build¶
Run¶
CLI Flags¶
| Flag | Default | Description |
|---|---|---|
--config |
Path to JSON config file | |
--host |
0.0.0.0 |
Listen address |
--port |
7540 |
Listen port |
--data-dir |
./data |
Data storage directory |
--storage |
disk |
Storage type: disk or memory |
--sync-write |
false |
Flush to disk on every write |
Environment Variables¶
CLI flags can also be set via environment variables. Environment variables are overridden by CLI flags.
| Variable | Equivalent Flag |
|---|---|
CRYSTAL_HOST |
--host |
CRYSTAL_PORT |
--port |
CRYSTAL_DATA_DIR |
--data-dir |
CRYSTAL_STORAGE |
--storage |
CRYSTAL_CONFIG |
--config |
Config File¶
Create a JSON config file:
{
"server": {
"host": "0.0.0.0",
"port": 7540,
"read_timeout": 30000000000,
"write_timeout": 30000000000,
"read_header_timeout": 10000000000,
"idle_timeout": 120000000000,
"shutdown_timeout": 15000000000,
"max_body_bytes": 4194304
},
"schema": {
"vertices": [
{
"type_name": "Person",
"constraints": [
{ "name": "name", "type": 0, "required": true }
]
}
],
"edges": []
},
"replication": {
"primary_url": "",
"graph": "",
"token": "",
"read_only": false,
"retry_interval": 2000000000
},
"auth": {
"enabled": true,
"tokens": [
{ "name": "admin", "token": "REPLACE-ME", "scope": "write" },
{ "name": "dashboard", "token": "REPLACE-ME-TOO", "scope": "read" }
]
},
"tls": {
"enabled": false,
"cert_file": "/etc/crystal/tls/crystal.crt",
"key_file": "/etc/crystal/tls/crystal.key"
},
"cors": {
"allowed_origins": []
},
"storage": {
"type": "disk",
"data_dir": "/var/lib/crystal",
"sync_on_write": false,
"flush_interval": 30000000000,
"max_wal_bytes": 67108864,
"default_graph": "default",
"max_graphs": 32
}
}
Timeout values are in nanoseconds (Go time.Duration). 30000000000 = 30 seconds.
Security¶
Crystal refuses to start with authentication enabled and no tokens configured,
which is the default. This is deliberate: the alternative is a graph database
listening on 0.0.0.0 that anyone with network reach can read, rewrite or
delete.
Generate a token and pass it in the environment rather than committing it:
Clients (including crystal-cli, which reads CRYSTAL_TOKEN) send it as
Authorization: Bearer <token>.
| Setting | Env | Default | Notes |
|---|---|---|---|
auth.enabled |
CRYSTAL_AUTH_DISABLED=true disables |
true |
Startup fails if enabled with no tokens |
auth.tokens[].scope |
— | write |
read grants GETs and POST /api/v1/query |
tls.enabled |
CRYSTAL_TLS_CERT, CRYSTAL_TLS_KEY |
false |
Both cert and key required |
cors.allowed_origins |
CRYSTAL_CORS_ORIGINS (comma-separated) |
empty (closed) | * rejected while auth is on |
server.max_body_bytes |
— | 4 MiB | Enforced with MaxBytesReader |
server.read_header_timeout |
— | 10s | Slow-header protection |
server.idle_timeout |
— | 120s | Keep-alive reaping |
To run without authentication — only reasonable when bound to loopback behind
another authenticating layer — set auth.enabled: false and bind to 127.0.0.1.
TLS¶
export CRYSTAL_TLS_CERT=/etc/crystal/tls/crystal.crt
export CRYSTAL_TLS_KEY=/etc/crystal/tls/crystal.key
./crystal
Crystal terminates TLS itself when configured. Behind a reverse proxy that already terminates TLS, leave it disabled and bind Crystal to loopback.
Scaling out¶
Full guide
This section is the summary. See the Cluster Mode guide for topologies, routing, monitoring replication lag and the failover runbook, and Service Levels for what is and is not guaranteed.
Crystal is RAM-resident (ADR 0001), so a single process is bounded by memory. Two mechanisms scale past one process, and they solve different problems.
Shard by graph — for capacity¶
One process serves several named graphs, and graphs are independent. Route each graph to whichever node has room:
curl -XPOST -H "Authorization: Bearer $TOKEN" \
-d '{"name":"case-2024-07"}' http://node-a:7540/api/v1/graphs
This fits investigation and narrative workloads almost exactly, because those are naturally partitioned: one investigation, one case or one story is one graph. Capacity then scales with node count, under one constraint — a single graph must fit on a single node.
Set storage.max_graphs per node. Every graph is resident in full, so the
memory cost of a process is the sum of its graphs.
Read replicas — for read throughput and availability¶
A follower consumes the primary's transaction stream and serves reads:
./crystal --data-dir /var/lib/crystal-replica \
--replica-of https://primary:7540
export CRYSTAL_REPLICATION_TOKEN=<read-scoped token on the primary>
A follower refuses all writes with 403. Its data is defined entirely by the primary's stream, so a local write would either be silently overwritten by the next transaction touching the same entity, or survive as divergence that nothing ever reconciles.
| Setting | Env | Meaning |
|---|---|---|
replication.primary_url |
CRYSTAL_REPLICA_OF |
Primary to follow; also implies read-only |
replication.graph |
— | Which graph to follow; empty follows the primary's default |
replication.token |
CRYSTAL_REPLICATION_TOKEN |
Token for the primary; read scope is sufficient |
replication.read_only |
— | Refuse writes without following anything |
replication.retry_interval |
— | Reconnection backoff (default 2s) |
What replication does and does not give you. It scales reads and gives you
a warm copy on another machine. It does not raise capacity — every replica
holds the whole graph — and it does not fail over automatically. Promotion
is an operator action: stop the follower, remove --replica-of, restart, and
repoint clients.
A follower that falls far enough behind that the primary has checkpointed away
the transactions it needs will re-bootstrap from a full snapshot automatically.
Raising max_wal_bytes and flush_interval on the primary widens that window.
Health Checks¶
| Endpoint | Auth | Touches storage | Use for |
|---|---|---|---|
/livez |
public | no | Kubernetes livenessProbe / restart decisions |
/readyz |
public | yes | readinessProbe / load-balancer pool membership |
/health, /api/v1/health |
public | yes | container healthcheck (aliases of /readyz) |
/api/v1/diagnostics |
token | yes | operator inspection |
Point restart logic at /livez and traffic routing at /readyz. Wiring both to
the same endpoint defeats the split: a storage failure would restart the pod
instead of depooling it, and restarting does not fix a disk.
Health Check¶
Returns 200 OK with runtime info when the server is healthy.
Systemd¶
Example unit file at deployments/systemd/crystal.service:
[Unit]
Description=Crystal Graph Engine
After=network.target
[Service]
Type=simple
User=crystal
ExecStart=/usr/local/bin/crystal --data-dir /var/lib/crystal
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Data Backup¶
The disk backend stores data in a data directory with two files:
graph.json-- full snapshot of the graph statewal.bin-- write-ahead log with mutations since the last snapshot
To backup, flush first to ensure a consistent snapshot, then copy:
curl -X POST http://localhost:7540/api/v1/graph/flush
cp /var/lib/crystal/graph.json /backup/crystal-$(date +%Y%m%d).json
To restore, stop Crystal, replace graph.json, remove wal.bin, and restart.
Crash Recovery¶
The disk backend uses a Write-Ahead Log (WAL) for crash safety. Every mutation is appended to wal.bin and fsynced before being applied in memory. On startup, Crystal:
- Loads the last
graph.jsonsnapshot - Replays any WAL entries written after the snapshot
- Writes a fresh snapshot and truncates the WAL
This means all acknowledged writes survive kill -9, OOM kills, and power loss. The only data that can be lost is a mutation that was interrupted mid-WAL-write (before fsync completed), which is at most one operation.
A background checkpointer writes a fresh snapshot and truncates the WAL at the configured flush_interval (default 30s). A second trigger fires inline once the WAL passes max_wal_bytes (default 64 MiB), so a burst of writes cannot outrun the timer. Together these bound WAL size and recovery time; setting both to zero removes the bound and the log grows until the disk fills.
Performance Considerations¶
- Every write does one fsync to the WAL file. On SSDs this is typically 0.1-1ms per mutation. For high-throughput batch loading, consider using the in-memory backend and exporting afterward.
- The checkpoint interval (
flush_interval) controls the tradeoff between WAL size and checkpoint overhead. Shorter intervals mean faster recovery but more frequent full-graph serialization. max_wal_bytesbounds the same tradeoff by volume rather than by time, which is what protects a write-heavy burst. The write that crosses the threshold pays for the checkpoint inline, so a very small value turns ordinary writes into snapshot latency.- The
sync_on_writeoption triggers a full snapshot on every mutation in addition to the WAL, and truncates the WAL afterwards. This is rarely needed now that the WAL provides per-mutation durability, and it makes every write cost a full-graph serialization. - The in-memory storage backend has no I/O overhead and is suitable for ephemeral workloads or testing.
- The current MVP stores the entire graph in memory regardless of backend. Plan for datasets that fit in RAM.