This guide covers deploying the Disc database server in production environments.
All configuration is driven by environment variables, which makes Disc compatible with container orchestration, PaaS platforms, and traditional VM deployments.
All Disc server settings are read at startup via create_server_from_env().
No restart is required for most infrastructure changes — redeploy the container or process with updated environment variables.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://localhost:5432/disc |
PostgreSQL connection string. Overrides the bundled instance DSN when set. |
DISC_HOST |
localhost |
IP address or hostname the HTTP server binds to. Set to 0.0.0.0 to accept external connections. |
DISC_PORT |
5656 |
TCP port the HTTP server listens on. |
DISC_MAX_CONNECTIONS |
100 |
Maximum number of PostgreSQL connections in the connection pool. |
DISC_REQUEST_TIMEOUT |
30000 |
Per-request timeout in milliseconds. Requests exceeding this limit return HTTP 408. |
DISC_ENABLE_CORS |
true |
Enable CORS headers on all responses. Set to false when behind a proxy that manages CORS. |
DISC_CORS_ORIGINS |
(unrestricted) | Comma-separated list of allowed origins, e.g. https://app.example.com,https://admin.example.com. When unset, all origins are permitted. |
DISC_ENABLE_WEBSOCKETS |
true |
Enable WebSocket upgrade handling on the same port as HTTP. |
DISC_JWT_SECRET |
(none) | Secret used to sign and verify JWT tokens. Required to enable authentication. Must be at least 32 characters. |
DISC_ENABLE_AUTH |
(auto) | Explicitly enable (true) or disable (false) the auth subsystem. When unset, auth is enabled automatically if DISC_JWT_SECRET is present. |
DISC_ENABLE_ACCESS_POLICIES |
(none) | Set to true to enforce object-level access policies defined in SDL. Requires DISC_PROTOCOL=full. |
DISC_CACHE_MAX_SIZE |
1000 |
Maximum number of entries in the query compilation and parse caches combined. Reduce on memory-constrained hosts. |
DISC_SLOW_QUERY_MS |
1000 |
Queries exceeding this threshold (in milliseconds) are logged as slow queries. Set to 0 to disable. |
DISC_RATE_LIMIT_RPM |
0 (disabled) |
Maximum requests per minute per client IP. Set to 0 to disable rate limiting. |
DISC_RATE_LIMIT_BURST |
(equals RPM) | Maximum burst size above the per-minute rate. Defaults to the same value as DISC_RATE_LIMIT_RPM. |
DISC_TLS_CERT |
(none) | Path to the PEM-encoded TLS certificate file. Both DISC_TLS_CERT and DISC_TLS_KEY must be set to enable TLS. |
DISC_TLS_KEY |
(none) | Path to the PEM-encoded TLS private key file. |
DISC_TLS_CERT_ENV |
(none) | Name of an env var holding the PEM cert contents (for K8s/Fly.io/Render env-only secret injection); materializes to a 0600 temp file at boot. |
DISC_TLS_KEY_ENV |
(none) | Name of an env var holding the PEM key contents. |
DISC_TLS_REDIRECT |
false |
When true, start a second listener on DISC_TLS_REDIRECT_PORT that issues HTTP 301 redirects to the HTTPS port. |
DISC_TLS_REDIRECT_PORT |
80 |
Port for the HTTP-to-HTTPS redirect listener. |
DISC_ENABLE_METRICS |
false |
Expose a Prometheus-compatible /metrics endpoint. Keep this disabled or firewall-protected in production. |
DISC_LOG_LEVEL |
INFO |
Log verbosity. One of DEBUG, INFO, WARN, ERROR. Use WARN or ERROR in production. |
DISC_LOG_FORMAT |
json |
Log output format. json for structured logging (recommended in production), text for human-readable output. |
DISC_EXPLAIN_CACHE_TTL |
300000 |
Time-to-live in milliseconds for cached EXPLAIN plan results. Default is 5 minutes. |
DISC_PROTOCOL |
simple |
Protocol handler to use. simple uses simulated compilation; full enables the real EdgeQL compiler with access policy support. |
DISC_SHUTDOWN_DRAIN_TIMEOUT |
30000 |
Maximum time in milliseconds to wait for in-flight requests to complete before forcing shutdown. |
Disc reads TLS certificate files directly from disk at startup. The server uses Deno’s native TLS support, so no external TLS library is required.
Generate a self-signed certificate for local testing:
openssl req -x509 -newkey rsa:4096 -nodes \
-keyout disc.key \
-out disc.crt \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
Start Disc with TLS enabled:
DISC_TLS_CERT=./disc.crt \
DISC_TLS_KEY=./disc.key \
DISC_PORT=5657 \
disc serve
Use certbot to obtain a certificate for a public domain:
certbot certonly --standalone \
--domain disc.example.com \
--email ops@example.com \
--agree-tos
Certificates are written to /etc/letsencrypt/live/disc.example.com/.
DISC_TLS_CERT=/etc/letsencrypt/live/disc.example.com/fullchain.pem \
DISC_TLS_KEY=/etc/letsencrypt/live/disc.example.com/privkey.pem \
DISC_TLS_REDIRECT=true \
DISC_TLS_REDIRECT_PORT=80 \
DISC_PORT=443 \
disc serve
Set up automatic renewal:
# Add to root crontab
0 3 * * * certbot renew --quiet && systemctl restart disc
Disc can reload TLS certificates from disk without a process restart — useful for certbot / cert-manager renewals on long-running production servers (server/tls-reload.ts, gh/geldata#4277).
Enable hot-reload via the tls.reload option:
const server = new DiscServer({
tls: {
certFile: "/etc/letsencrypt/live/disc.example.com/fullchain.pem",
keyFile: "/etc/letsencrypt/live/disc.example.com/privkey.pem",
reload: true,
reloadDebounceMs: 500 // optional, default 500
}
});
When enabled, Disc watches both files via Deno.watchFs. After a debounce window (reloadDebounceMs, default 500 ms) collapses the burst of events that renewal tools emit when they write the key and cert back-to-back, the server:
Expect a sub-second blip in connection accepts during the swap. New requests during the swap window queue at the OS socket level and proceed once the new listener binds. There is no full process restart, no in-flight request loss, and no cache flush.
If the new cert fails cryptographic validation (mismatched key, expired, malformed), the server logs an error and keeps the old listener running — a half-rotation never lands.
When TLS is configured, Disc exports two Prometheus gauges (server/tls-cert-info.ts, gh/geldata#6205):
| Metric | Type | Description |
|---|---|---|
disc_tls_certificate_expiration_time |
gauge | Leaf certificate notAfter as Unix epoch seconds. |
disc_tls_certificate_seconds_until_expiry |
gauge | Seconds until expiry. Negative means expired. |
Use the second one for alerting:
# prometheus-alerts.yml
groups:
- name: disc-tls
rules:
- alert: DiscTlsCertExpiringSoon
expr: disc_tls_certificate_seconds_until_expiry < 7 * 24 * 3600
for: 10m
annotations:
summary: "Disc TLS cert expires in less than 7 days"
- alert: DiscTlsCertExpired
expr: disc_tls_certificate_seconds_until_expiry < 0
for: 1m
annotations:
summary: "Disc TLS cert is expired"
The gauges refresh on initial bind and after every successful TLS hot-reload, so certbot renew followed by Disc swapping the cert in-place updates the metric without a Disc restart.
Terminate TLS at the load balancer or reverse proxy and forward plain HTTP to Disc.
This is the most common pattern for production deployments.
Nginx example (TLS termination in front of Disc):
upstream disc {
server 127.0.0.1:5656;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name disc.example.com;
ssl_certificate /etc/letsencrypt/live/disc.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/disc.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://disc;
proxy_http_version 1.1;
# Required for WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_read_timeout 35s;
proxy_send_timeout 35s;
}
}
server {
listen 80;
server_name disc.example.com;
return 301 https://$host$request_uri;
}
Caddy example (automatic HTTPS in front of Disc):
disc.example.com {
# Caddy obtains and renews the TLS certificate from Let's Encrypt
# automatically -- no certbot, cron job, or manual cert paths required.
reverse_proxy 127.0.0.1:5656 {
# WebSocket upgrades are proxied transparently; no extra config needed.
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Match Disc's request timeout (DISC_REQUEST_TIMEOUT, default 30s) with headroom.
transport http {
read_timeout 35s
write_timeout 35s
}
}
}
Caddy listens on :443 (and redirects :80 to :443) by default, so the HTTP-to-HTTPS redirect and certificate lifecycle are handled for you. With this setup leave DISC_TLS_CERT/DISC_TLS_KEY unset on Disc and let Caddy terminate TLS.
When using a reverse proxy, bind Disc to localhost only:
DISC_HOST=127.0.0.1
DISC_ENABLE_CORS=false
Disc maintains a pool of PostgreSQL connections. The total connections across all Disc instances must stay below PostgreSQL’s max_connections limit, with headroom reserved for administrative connections.
Formula:
DISC_MAX_CONNECTIONS * disc_instance_count <= pg_max_connections - 5
| Deployment Size | DISC_MAX_CONNECTIONS |
PostgreSQL max_connections |
Disc Instances |
|---|---|---|---|
| Development | 10 | 100 | 1 |
| Small (< 100 req/s) | 25 | 100 | 2 |
| Medium (< 500 req/s) | 50 | 200 | 2-4 |
| Large (< 2000 req/s) | 100 | 500 | 3-5 |
| High-availability | 50 | 500 | 8+ |
PostgreSQL connection overhead: Each connection consumes approximately 5-10 MB of shared memory on the PostgreSQL side. Do not set max_connections higher than needed on the database server.
PgBouncer: For high-concurrency deployments, place PgBouncer in transaction mode between Disc and PostgreSQL. Set DISC_MAX_CONNECTIONS to the PgBouncer pool size and configure PgBouncer’s max_client_conn to match your PostgreSQL limit.
Disc exposes three health endpoints:
| Endpoint | Purpose | Success | Failure |
|---|---|---|---|
GET /health |
Full status with database ping, pool stats, and uptime | HTTP 200 | HTTP 503 |
GET /health/live |
Liveness — is the process running? | HTTP 200 {"status":"alive"} |
Process not running |
GET /health/ready |
Readiness — is the database reachable? | HTTP 200 {"status":"healthy"} |
HTTP 503 {"status":"unhealthy"} |
livenessProbe:
failureThreshold: 3
httpGet:
path: /health/live
port: 5656
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
failureThreshold: 2
httpGet:
path: /health/ready
port: 5656
initialDelaySeconds: 10
periodSeconds: 5
startupProbe:
failureThreshold: 12
httpGet:
path: /health
port: 5656
initialDelaySeconds: 15
periodSeconds: 5
In the target group settings:
/health/readyhealthcheck:
interval: 15s
retries: 3
start_period: 20s
test: ["CMD", "curl", "-sf", "http://localhost:5656/health/ready"]
timeout: 5s
Disc applies per-IP rate limiting using a token bucket algorithm. Burst allows short spikes above the per-minute rate.
| Use Case | DISC_RATE_LIMIT_RPM |
DISC_RATE_LIMIT_BURST |
|---|---|---|
| Public API | 60 |
20 |
| Authenticated API | 300 |
50 |
| Internal service (trusted network) | 600 |
100 |
| Development / local | 0 (disabled) |
— |
When a client exceeds the rate limit, Disc returns:
HTTP 429 Too Many Requests
Retry-After: 60
{"error": "Rate limit exceeded"}
Important: If Disc is behind a reverse proxy, the rate limiter sees the proxy’s IP rather than the real client IP. Ensure the proxy forwards X-Real-IP or X-Forwarded-For, and configure your infrastructure so Disc can trust these headers.
Consider applying rate limiting at the proxy layer instead for proxy deployments.
DISC_LOG_FORMAT=json (recommended for production):
{
"duration_ms": 12,
"level": "INFO",
"msg": "Query executed",
"query_hash": "a3f9b2",
"time": "2026-03-17T10:00:00.000Z"
}
DISC_LOG_FORMAT=text (useful for local development):
INFO 2026-03-17T10:00:00.000Z Query executed duration_ms=12
| Environment | DISC_LOG_LEVEL |
|---|---|
| Production | WARN |
| Staging | INFO |
| Development | DEBUG |
Elastic (ELK) via Filebeat:
# filebeat.yml
filebeat.inputs:
- type: container
paths:
- /var/lib/docker/containers/*/*.log
processors:
- add_docker_metadata: ~
- decode_json_fields:
fields: ["message"]
target: ""
overwrite_keys: true
output.elasticsearch:
hosts: ["https://elasticsearch:9200"]
index: "disc-logs-%{+yyyy.MM.dd}"
Grafana Loki via Promtail:
# promtail-config.yml
scrape_configs:
- job_name: disc
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: disc.*
action: keep
- source_labels: [__meta_docker_container_name]
target_label: container
AWS CloudWatch via Fluent Bit:
[INPUT]
Name tail
Path /var/log/disc/*.log
Parser json
Tag disc.*
[OUTPUT]
Name cloudwatch_logs
Match disc.*
region us-east-1
log_group_name /disc/production
log_stream_prefix disc-
auto_create_group true
Enable the /metrics endpoint by setting DISC_ENABLE_METRICS=true. The endpoint returns metrics in Prometheus text exposition format (content type text/plain; version=0.0.4).
Important: Do not expose /metrics publicly. Restrict access via firewall rules, a network policy, or a separate internal port at the proxy layer.
scrape_configs:
- job_name: disc
static_configs:
- targets: ["disc-internal:5656"]
metrics_path: /metrics
scrape_interval: 15s
scrape_timeout: 10s
| Metric | Type | Alert Condition |
|---|---|---|
disc_http_requests_total |
Counter | Sudden drop to 0 (server down) |
disc_http_errors_total |
Counter | Error rate > 1% sustained |
disc_http_request_duration_ms |
Gauge (avg) | p99 > request timeout |
disc_pool_active_connections |
Gauge | Approaches DISC_MAX_CONNECTIONS |
disc_pool_waiters |
Gauge | Sustained value > 0 |
disc_cache_hit_rate |
Gauge | Falls below 0.7 |
disc_query_avg_compile_ms |
Gauge | Rising trend indicates schema complexity growth |
disc_rate_limit_rejected_total |
Counter | Spikes indicate client misconfiguration or abuse |
disc_memory_heap_used_bytes |
Gauge | Sustained growth (memory leak) |
Create panels for:
Disc ships with production-ready Docker files in the repository root.
Use the multi-stage Dockerfile for production deployments with an external PostgreSQL.
This image caches dependencies in a separate stage, removes test and documentation files, runs as a non-root user, and includes a built-in health check.
docker build -t disc .
docker run -e DATABASE_URL="postgres://user:pass@host:5432/disc" -p 5656:5656 disc
The entrypoint supports running any CLI subcommand:
docker run disc migrate # Run migrations
docker run disc shell # Open EdgeQL shell
See Dockerfile for the full multi-stage build definition.
Use Dockerfile.bundled for development, demos, or single-container deployments.
This image installs PostgreSQL 16 inside the container and manages its lifecycle automatically via an entrypoint script.
docker build -f Dockerfile.bundled -t disc-bundled .
docker run -p 5656:5656 disc-bundled
No DATABASE_URL is required -- the entrypoint initializes PostgreSQL, creates the database, and connects Disc automatically.
See Dockerfile.bundled for the full build definition and entrypoint script.
The repository includes two compose files:
docker-compose.yml -- production stack with Disc and PostgreSQLdocker-compose.monitoring.yml -- overlay that adds Prometheus and Grafana# Production stack (Disc + PostgreSQL)
docker compose up -d
# With monitoring (adds Prometheus + Grafana)
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# View logs
docker compose logs -f disc
# Stop
docker compose down
The monitoring overlay expects a Prometheus config at deploy/prometheus.yml and automatically sets DISC_ENABLE_METRICS=true on the Disc service.
Convenience tasks are available in deno.json:
deno task docker:build # Build the production image
deno task docker:up # Start the compose stack
deno task docker:down # Stop the compose stack
Disc can be compiled to a self-contained native binary using Deno’s deno compile.
The resulting binary requires no runtime installation -- it embeds Deno and all dependencies.
# Build for the current platform
disc build
# Or via Deno task
deno task build
The binary is written to ./disc by default.
Target a specific platform with the --platform flag:
disc build --platform linux-x64
disc build --platform linux-arm64
disc build --platform darwin-x64
disc build --platform darwin-arm64
disc build --platform windows-x64
Platform-specific Deno tasks are also available:
deno task build:linux-x64
deno task build:linux-arm64
deno task build:darwin-x64
deno task build:darwin-arm64
deno task build:windows-x64
When cross-compiling, the output binary is named ./disc-{platform} (e.g., ./disc-linux-x64); the windows-x64 target adds a .exe extension (./disc-windows-x64.exe). You can override the output path with --output:
disc build --platform linux-x64 --output ./dist/disc-server
| Platform | Deno Target |
|---|---|
darwin-arm64 |
aarch64-apple-darwin |
darwin-x64 |
x86_64-apple-darwin |
linux-arm64 |
aarch64-unknown-linux-gnu |
linux-x64 |
x86_64-unknown-linux-gnu |
windows-x64 |
x86_64-pc-windows-msvc |
Deno’s cross-compilation downloads a platform-specific runtime snapshot. This works reliably for most cases, but note:
windows-x64 target is produced and shipped by the build/release pipeline (with PostgreSQL embedded from Zonky’s windows-amd64 distribution), but the bundled-PostgreSQL runtime lifecycle on Windows is not yet validated — use the --backend-dsn external-PostgreSQL escape hatch on Windows for nowThe compiled binary can be deployed directly to a VM or bare-metal server:
# Build for Linux
disc build --platform linux-x64
# Copy to server
scp ./disc-linux-x64 server:/usr/local/bin/disc
# Run on server
ssh server "DATABASE_URL=postgres://... /usr/local/bin/disc serve"
This pairs well with the systemd service unit generated by disc deploy --format systemd.
Disc never runs PostgreSQL as root -- PostgreSQL’s initdb and postgres binaries refuse to start under uid 0, so the bundled-PostgreSQL path requires a dedicated non-root OS user. (External PostgreSQL via DATABASE_URL has no such constraint, but running the server itself as a non-root user is still recommended.) The generated disc.service already declares User=disc; this walkthrough sets up the matching user and directories on a fresh Linux VPS.
# 1. Create a dedicated, login-less system user.
sudo useradd --system --create-home --shell /usr/sbin/nologin disc
# 2. Lay out the working and data directories owned by that user.
sudo mkdir -p /opt/disc /var/lib/disc /etc/disc
sudo chown -R disc:disc /opt/disc /var/lib/disc
# 3. Install the compiled binary (built with `disc build --platform linux-x64`).
sudo install -o disc -g disc -m 0755 ./disc-linux-x64 /opt/disc/disc
# 4. Write the environment file the unit reads (EnvironmentFile=/etc/disc/disc.env).
sudo tee /etc/disc/disc.env >/dev/null <<'ENV'
# Data root. Override HOME (not DISC_HOME): the bundled-PostgreSQL instance
# manager resolves its data dir from $HOME/.disc/instances, while the project
# context resolves from $DISC_HOME -- setting only DISC_HOME makes the two
# disagree. Pointing HOME at /opt/disc makes every resolver agree on
# /opt/disc/.disc, which lives inside the unit's ReadWritePaths. ProtectHome=true
# only blanks /home and /root, so a HOME under /opt stays writable.
HOME=/opt/disc
DISC_HOST=127.0.0.1
DISC_PORT=5656
# Bundled PostgreSQL needs no DATABASE_URL -- the embedded distribution is
# extracted under $HOME/.disc and managed automatically, all as the disc user.
# To use external PostgreSQL instead, set DATABASE_URL here AND set managed=false
# / backend_dsn in disc.toml (step 4b) so the server connects out:
# DATABASE_URL=postgres://disc_app:CHANGE_ME@127.0.0.1:5432/disc
ENV
sudo chown disc:disc /etc/disc/disc.env
sudo chmod 0640 /etc/disc/disc.env
# 4b. Write the project config. Without a disc.toml on (or above) the unit's
# WorkingDirectory (/opt/disc), `disc serve` finds no project context, never
# starts the bundled PostgreSQL, and falls back to a TCP DSN nothing serves
# (ConnectionRefused). For external PostgreSQL, set managed = false and add
# a backend_dsn line instead of the instance_name line.
sudo tee /opt/disc/disc.toml >/dev/null <<'TOML'
# Disc Project Configuration
name = "disc"
[database]
# Managed PostgreSQL instance (bundled, started by disc serve)
managed = true
instance_name = "disc"
[server]
port = 5656
host = "127.0.0.1"
TOML
sudo chown disc:disc /opt/disc/disc.toml
# 5. Install and start the unit generated by `disc deploy --format systemd`.
disc deploy --format systemd --output /tmp/disc-deploy
sudo cp /tmp/disc-deploy/disc.service /etc/systemd/system/disc.service
sudo systemctl daemon-reload
sudo systemctl enable --now disc
# 6. Verify it came up as the disc user, not root.
systemctl status disc
ps -o user,cmd -C postgres # the postgres backends run as `disc`
The unit’s hardening (NoNewPrivileges=true, ProtectSystem=strict, ProtectHome=true, ReadWritePaths=/opt/disc /var/lib/disc) assumes exactly this layout. If you relocate the data root, update HOME in the env file, the disc.toml location to match WorkingDirectory, and ReadWritePaths/WorkingDirectory in the unit so the disc user can still write to its $HOME/.disc instance data. To bind a privileged port (80/443) without root, front Disc with a reverse proxy (see the Reverse Proxy TLS Termination section) rather than granting the process extra capabilities.
If you’d rather pull the prebuilt release binary than run disc build yourself, the curl -fsSL https://disc.sh/install | sh installer can replace steps 1’s build and step 3’s install. Note that the installer is a per-user CLI install by default — it downloads to $DISC_INSTALL/bin/disc (default ~/.disc/bin/disc), edits the invoking user’s shell rc for PATH, and installs man pages. To redirect it into the service layout above, point DISC_INSTALL at /opt/disc and suppress the rc/man steps:
# Fetch the binary into the service layout (lands at /opt/disc/bin/disc).
sudo DISC_INSTALL=/opt/disc sh -c \
'curl -fsSL https://disc.sh/install | sh -s -- --no-modify-path --no-man'
# Hand ownership to the disc service user so it can execute the binary and
# write its bundled-PostgreSQL data under $HOME/.disc.
sudo chown -R disc:disc /opt/disc
--no-modify-path and --no-man matter here: the disc account is nologin, so there is no shell rc worth editing, and you don’t want the installer prompting for sudo mid-provision.
The installer lands the binary at /opt/disc/bin/disc, but the generated unit’s ExecStart is the literal /opt/disc/disc serve — the generator targets the disc build single-binary layout and has no way to know the curl installer’s bin/ path (systemd does not support ${VAR:-default} expansion, so it cannot be redirected via an env var). Rather than reach for a symlink, normalize the generated unit in place so its directives match your actual layout. This survives binary-version drift: the ^Directive= anchors overwrite the value whether it’s literal or — from a binary predating the literal-value fix — an unsupported ${VAR:-default} (which systemd rejects with bad unit file setting):
disc deploy --format systemd --output /tmp/disc-deploy
sed -i \
-e "s#^ExecStart=.*#ExecStart=/opt/disc/bin/disc serve#" \
-e "s#^ReadWritePaths=.*#ReadWritePaths=/opt/disc#" \
/tmp/disc-deploy/disc.service
sudo cp /tmp/disc-deploy/disc.service /etc/systemd/system/disc.service
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/disc.service # catch a bad setting before enable
sudo systemctl enable --now disc
The full provisioning flow — user, env file, directory ownership, and this normalization — is packaged as deploy/install-disc.sh, which honors DISC_USER/DISC_PREFIX/DISC_PORT/DATABASE_URL overrides. The manual steps below remain useful for understanding what it does.
Because --no-modify-path skips all shell-rc editing, disc will not be on any user’s PATH after this install — by design, since systemd invokes the binary by absolute path and the disc account is nologin. The daemon needs nothing further, but you’ll want the CLI on hand for admin commands (disc migrate, disc shell, disc status) and for step 5’s disc deploy --format systemd. Symlink it into a directory already on PATH:
sudo ln -s /opt/disc/bin/disc /usr/local/bin/disc
disc --version # now resolves for every user
If disc is still not found after symlinking, your shell’s PATH may not include /usr/local/bin — some minimal or hardened VPS images omit it. Confirm with echo $PATH, then either add the directory:
echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
or symlink into /usr/bin instead (effectively always on PATH): sudo ln -s /opt/disc/bin/disc /usr/bin/disc.
Commands that touch the database (migrate, shell) must run as the disc user with the unit’s environment, since the bundled-PostgreSQL data under /opt/disc is owned by disc and the connection settings live in /etc/disc/disc.env (read by systemd, not your interactive shell):
sudo -u disc env $(grep -v '^#' /etc/disc/disc.env | xargs) disc migrate
Everything else in the walkthrough — the dedicated user from step 1, the /etc/disc/disc.env from step 4, and the unit install from step 5 — is unchanged.
The disc deploy command generates project-specific deployment artifacts. Instead of writing Dockerfiles, compose files, or service units from scratch, use the scaffold generator to produce a starting point tailored to your project.
disc deploy --format <format> [--output <directory>]
The --output flag controls where generated files are written. Defaults to ./deploy.
| Format | Output File | Description |
|---|---|---|
docker |
Dockerfile |
Basic Dockerfile for running Disc with external PG |
compose |
docker-compose.yml |
Compose stack with Disc and PostgreSQL |
systemd |
disc.service |
systemd service unit for Linux deployments |
env |
.env.production |
Environment variable template with all Disc settings |
Generate an environment variable template:
disc deploy --format env
# Writes ./deploy/.env.production
Generate a systemd service unit to a custom directory:
disc deploy --format systemd --output ./infra
# Writes ./infra/disc.service
Generate a docker-compose file:
disc deploy --format compose
# Writes ./deploy/docker-compose.yml
The generated files include comments indicating they were scaffolded by disc deploy and are meant to be customized for your specific infrastructure.
Disc ships a Helm chart at deploy/helm/disc for self-hosting on Kubernetes. Unlike disc deploy, the chart is not generated per-project -- it is a versioned artifact in the repository, tracked against the Disc release (appVersion in Chart.yaml is bumped by deno task version:bump).
Deployment running the Disc server image (ghcr.io/systemsoft/disc)Service (ClusterIP by default) exposing port 5656Secret holding DATABASE_URL and DISC_JWT_SECRET, unless you bring your ownIngress, HorizontalPodAutoscaler, PodDisruptionBudget, ServiceMonitor, and NetworkPolicyExternal PostgreSQL only. The chart does not deploy the bundled PostgreSQL that ships with the Disc CLI -- that path is a local-development convenience. Point the chart at managed PostgreSQL 16+ (RDS, Cloud SQL, AlloyDB, the Bitnami chart, Crunchy Postgres Operator, etc.).
ServiceMonitor support (optional)helm install disc ./deploy/helm/disc \
--set database.external.url='postgres://disc:secret@my-pg.example.com:5432/disc'
The chart generates a JWT signing secret on first install and preserves it across upgrades via helm.sh/resource-policy: keep.
helm upgrade disc ./deploy/helm/disc -f my-values.yaml
Rolling updates use maxSurge: 1 / maxUnavailable: 0, so a healthy replica always serves during the rollout. terminationGracePeriodSeconds is 30 to match Disc's shutdownDrainTimeout (see Graceful Shutdown).
values.yaml documents every option inline. The overrides most deployments need:
| Path | Purpose |
|---|---|
image.tag |
Pin to an exact image tag |
replicaCount |
HA replica count |
database.external.url / database.external.urlSecretRef |
Connect to your PostgreSQL |
auth.jwtSecret / auth.jwtSecretRef |
Provide your own JWT secret |
tls.enabled, tls.certSecret |
In-pod TLS termination |
ingress.enabled, ingress.hosts, ingress.tls |
Expose to the world |
autoscaling.enabled |
Turn on the HPA |
metrics.enabled, metrics.serviceMonitor.enabled |
Wire up Prometheus |
values-prod-example.yaml in the chart directory is a production-grade overlay to start from.
These are configured at SDK-instantiation time in your own code rather than through DISC_* environment variables, so the chart cannot template them directly. To wire them into a cluster today, mount your config as a file via extraVolumes / extraVolumeMounts and reference it from your bootstrap script. See Auth → Email Delivery (SMTP) for the config shape.
Before going live, verify each item:
DISC_JWT_SECRET is set to a randomly generated string of 32 or more charactersDISC_RATE_LIMIT_RPM is set to a value appropriate for your traffic patternDISC_CORS_ORIGINS is restricted to your application’s domains; not left as wildcard in productionDISC_ENABLE_ACCESS_POLICIES=true if serving multiple tenants or users with different data access rightsDISC_ENABLE_METRICS=false (default) or the /metrics endpoint is firewalled from public accessDISC_LOG_LEVEL=WARN in production to avoid logging sensitive query contentDATABASE_URL credentials use a dedicated database user with only the required privileges; not the PostgreSQL superuserDISC_HOST=127.0.0.1 when behind a reverse proxy (do not bind to 0.0.0.0 unless required)Disc handles SIGINT and SIGTERM signals with an ordered shutdown sequence.
This ensures in-flight requests complete and connections are cleanly released.
Shutdown sequence:
SIGINT or SIGTERM)DISC_SHUTDOWN_DRAIN_TIMEOUT milliseconds for in-flight requests to finish (polls every 100 ms)The default drain timeout is 30 seconds. For long-running query workloads, increase this value to match your expected maximum query duration:
DISC_SHUTDOWN_DRAIN_TIMEOUT=60000 # 60 seconds
In Kubernetes, set terminationGracePeriodSeconds to at least DISC_SHUTDOWN_DRAIN_TIMEOUT / 1000 + 5 to give Disc enough time to drain before the kubelet force-kills the pod.
spec:
terminationGracePeriodSeconds: 40
Symptoms:
DISC_MAX_CONNECTIONS gauge in /metrics is at maximumdisc_pool_waiters metric is consistently above 0/health/ready returns HTTP 503 with {"status":"unhealthy"}Diagnosis:
# Check pool stats in real time
curl -s http://localhost:5656/stats | jq ".cache, .query_metrics"
curl -s http://localhost:5656/health | jq ".pool"
Fix:
DISC_MAX_CONNECTIONS if PostgreSQL max_connections allows headroomSymptoms:
disc_memory_heap_used_bytes grows over time without leveling offDiagnosis:
curl -s http://localhost:5656/stats | jq ".memory_usage, .cache"
Fix:
DISC_CACHE_MAX_SIZE. The query cache holds compiled query plans in memory. A value of 500 is sufficient for most schemas.DISC_EXPLAIN_CACHE_TTL to evict cached EXPLAIN results more frequentlySymptoms:
/statsslow_query=true when DISC_SLOW_QUERY_MS is setDiagnosis:
Enable slow query logging at an appropriate threshold:
DISC_SLOW_QUERY_MS=200 # Log queries taking more than 200ms
DISC_LOG_LEVEL=INFO
Then check logs for the query text and use PostgreSQL EXPLAIN ANALYZE directly:
EXPLAIN ANALYZE SELECT ...;
Fix:
Symptoms:
disc_rate_limit_rejected_total metric increasing during normal trafficDiagnosis:
curl -s http://localhost:5656/stats | jq ".rate_limit"
Fix:
DISC_RATE_LIMIT_RPM for the traffic patternDISC_RATE_LIMIT_BURST to absorb bursty but legitimate clientsSymptoms:
Common causes and fixes:
| Error | Cause | Fix |
|---|---|---|
cert file not found |
DISC_TLS_CERT path is wrong or not mounted |
Verify file path and container volume mounts |
key does not match cert |
Certificate and key are mismatched | Re-generate or ensure the correct pair is used |
certificate expired |
Let’s Encrypt renewal failed | Run certbot renew manually; verify cron job |
ERR_CERT_AUTHORITY_INVALID |
Self-signed cert not trusted by client | Use a CA-signed cert or add the self-signed cert to the client trust store |
Verify the certificate before starting Disc:
openssl x509 -in "$DISC_TLS_CERT" -noout -text | grep -E "Not After|Subject:"
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$DISC_TLS_CERT"