Single-server production deployment
Audience: DBA / platform engineer deploying for production use. Scenario: one large server, multiple concurrent users, an existing PostgreSQL instance and an existing S3-compatible object store. The examples use rustfs; MinIO, Ceph RGW, or any other S3 API works the same way.
In a hurry? Jump straight to Appendix A: one commented script that takes a fresh server to a served database (three dual nodes on rustfs), then Appendix B for the roles and groups scripts. Come back to the sections when you need the why behind a line.
Outgrowing one server but no Kubernetes? The fleet runtime spreads the nodes over several Linux or macOS servers that join with
qod fleet join, one node per server. The Postgres, object store, provisioning, authentication and monitoring sections of this page carry over; node sizing moves from shared-host budgets to one node per server.
0. Recommended architecture
The two external dependencies QoD needs (Postgres and an S3-compatible store) are already in place, so the recommended layout is:
| Component | Where it runs | Role |
|---|---|---|
| QoD manager | The server (one JVM process) | REST control plane + admin UI on :20900, FlightSQL edge on :31338, native Quack front door on :9494 (DuckDB ATTACH) |
| Quack nodes | The server, spawned as local subprocesses (runtimeType = "local") | DuckDB engines executing user SQL |
| Control-plane state | Existing PostgreSQL (database qod) | Tenants, pools, users, RBAC, audit, statement history |
| DuckLake metastore | Existing PostgreSQL (one database per tenant-db, <tenant>_<name>) | Table/snapshot metadata |
| Data files | Existing rustfs, via its S3 API (s3://...) | Parquet data written by DuckLake |
| Grafana | Anywhere with network access to the server | Dashboards (see sections 7 and 8) |
Key decisions:
- Local runtime. On a single machine
runtimeType = "local"(the default) spawns nodes as subprocesses of the manager; no container platform is required. - Point the default metastore at the existing Postgres with the
QOD_PG_*variables. Every tenant-db created without explicit metastore overrides inherits it. - Point the data path at rustfs using an
s3://data path plus S3 credentials (path-style URLs). Optionally enable managed storage so QoD carves per-database prefixes out of one root bucket:
export QOD_MANAGED_STORE_ENABLED=true
export QOD_MANAGED_STORE_ENDPOINT=http://rustfs:9000
export QOD_MANAGED_STORE_REGION=us-east-1
export QOD_MANAGED_STORE_BUCKET=qod-managed
export QOD_MANAGED_STORE_ACCESS_KEY_ID=...
export QOD_MANAGED_STORE_SECRET_ACCESS_KEY=...
export QOD_MANAGED_STORE_URL_STYLE=path
Important: the rustfs bucket used as the managed root must have versioning OFF, otherwise deleted data is never really purged.
4. Enable security before exposing anything: QOD_ACL_ENABLED=true (enforce grants), rotate QOD_ADMIN_PASSWORD, keep TLS on the FlightSQL edge, set QOD_API_KEY or rely on login sessions for /api.
0.1 The complete environment for this deployment
# --- Control plane + default DuckLake metastore: the existing Postgres ---
export QOD_PG_HOST=pg.internal
export QOD_PG_PORT=5432
export QOD_PG_USER=qod # must hold CREATEDB (QoD creates one DB per tenant-db)
export QOD_PG_PASSWORD=...
export QOD_PG_DBNAME=qod # control-plane database
# --- Data files: the existing rustfs, via its S3 API ---
export QOD_DUCKLAKE_DATA_PATH=s3://ducklake/qod # bucket must already exist (see below)
export QOD_S3_ENDPOINT=rustfs.internal:9000
export QOD_S3_ACCESS_KEY_ID=...
export QOD_S3_SECRET_ACCESS_KEY=...
export QOD_S3_REGION=us-east-1
export QOD_S3_URL_STYLE=path # path style for rustfs/MinIO (vhost is for AWS)
export QOD_S3_USE_SSL=false # true if rustfs is behind TLS
# --- Security ---
export QOD_ACL_ENABLED=true
export QOD_ADMIN_PASSWORD='<strong password>'
# --- Manager JVM heap (no default is set otherwise; never give it more than 2g) ---
export JAVA_OPTS=-Xmx2g
rustfs caveats to respect:
- Create the bucket first (
aws s3 mb s3://ducklake --endpoint-url http://rustfs.internal:9000). rustfs does not auto-create buckets, and a missing bucket surfaces later as a confusing S3 error at node spawn. - Versioning OFF on any bucket used by the managed store, otherwise purges leave invisible billed versions behind.
- Keep one Postgres metastore database per storage location: the DuckLake catalog persists the absolute data path on first write, so re-pointing an existing metastore at a different path breaks.
- If the site uses an HTTP proxy, add rustfs and Postgres hosts to
NO_PROXY, or DuckDB's S3 writes route through the proxy and fail.
0.2 Boot and verify
Use the native qod CLI, not Docker. The manager spawns Quack nodes as local subprocesses, so running natively keeps the per-node CPU/memory budgets of section 3 exact; a single container would put the manager and every node under one shared cgroup. The docker-compose stack is an evaluation rig that bundles its own Postgres, rustfs, and Grafana (all already in place here) with insecure demo defaults.
| Step | Command | Expected result |
|---|---|---|
| 1. Install the CLI | pip install qod | qod on PATH. Nothing else to install: the CLI provisions the JRE and the pinned DuckDB itself |
| 2. Load the environment | export the block from 0.1 | Postgres, rustfs, and security variables set in the shell |
| 3. Start the manager | qod start | First boot downloads the JRE and DuckDB, generates TLS certs, and creates the control-plane database; log shows auth: providers configured |
| 4. Check health | qod status | Manager healthy, FlightSQL edge port listening |
| 5. Verify the UI | open http://<host>:20900/ui/ | Login page; the rotated QOD_ADMIN_PASSWORD works |
| 6. Verify metrics | curl http://<host>:20900/metrics | Prometheus text output (section 7) |
| 7. Stop | qod stop | Clean shutdown |
Notes:
- The manager creates its own Postgres databases over JDBC at startup (0.8.3+): the control-plane database at preflight and one database per tenant-db at bootstrap, which is why the QoD Postgres user needs
CREATEDB. On earlier releases, create the control-plane database once by hand (CREATE DATABASE qod;). - A system
duckdbon PATH is deliberately ignored to avoid ABI mismatches. - For production, wrap
qod startin a systemd unit so the manager restarts on boot; reconcile respawns the node processes automatically.
1. How QoD serves multiple users simultaneously on one server
QoD is a multi-tenant SQL gateway built exactly for this:
- Users connect concurrently with their own credentials, over Arrow Flight SQL (
:31338) or DuckDB's native Quack protocol (:9494); both land in the same authentication, ACL and routing path. - The manager routes each statement to a node (DuckDB process) in the target pool using least-loaded routing (cache-aware placement on object-store pools), so concurrent statements spread across nodes.
- A single node also executes multiple statements simultaneously: sessions share the node's
threadsandmemory_limitbudgets, and the per-poolmaxConcurrentPerNodecap (default unbounded) bounds how many stack up on one node.
2. System requirements
2.1 Software prerequisites
| Component | Requirement |
|---|---|
| PostgreSQL | 16+; the QoD Postgres user needs CREATEDB (one database is auto-created per tenant-db) |
| OS | Linux or macOS; Windows native is experimental (PowerShell scripts), Docker on Windows requires WSL2 |
Everything else is provisioned by qod start itself: a Temurin 21 JRE when no 21+ JVM is present (JAVA_BIN forces a specific one) and the pinned DuckDB build (a system duckdb on PATH is deliberately ignored to avoid ABI mismatches).
2.2 Hardware guidance
| Component | Guidance |
|---|---|
| Manager JVM | Modest: plan 2 CPUs. Set the heap explicitly to JAVA_OPTS=-Xmx2g and never more than 2 GB; no default -Xmx is applied. RAM belongs to the nodes, not the manager |
| Node (DuckDB process) | Keep roughly 1 vCPU : 4 GiB RAM; do not go below 4 GiB per node. This matches DuckDB's official guidance of 1 to 4 GB per thread, with 3 to 4 GB per thread for join-heavy workloads (duckdb.org performance guide) |
| Data-size heuristic | A node handles TPC-H at a scale factor of roughly 3x its RAM in GiB (e.g. 32 GiB node: SF100, about 26 GB stored) |
| Disk | Fast local disk for DuckDB spill (temp_directory); spill must never point at the object store |
Sizing validation and the known limitations to plan around live in Appendix C.
3. Configuring CPU, threads, and memory limits
The single most important single-server fact: nothing caps a node by default. DuckDB's own defaults read the host: each node would claim about 80% of the machine's RAM and all its cores. With several nodes on one server you must set explicit per-node budgets.
3.1 Per-node DuckDB budgets via initSql
Set them per database (every node serving that database) or per pool. Pool initSql runs after database initSql, so a pool-level SET wins; use that to give the etl pool a bigger budget than bi.
# Database-level default for all pools of acme_sales
qod database update --tenant acme --name acme_sales \
--init-sql "SET memory_limit='24GB'; SET threads=8; SET temp_directory='/fastdisk/duckdb-tmp';"
Rules:
memory_limit: per-node share of RAM, at about 80% so DuckDB spills to disk before the OS OOM-killer fires.threads: per-node CPU share; keep close to the 1 vCPU : 4 GiB ratio.temp_directory: fast local disk, never the object store.- Never put credentials in
initSql(it is stored unredacted); engine settings only. - Editing a database's
initSqlrestarts its nodes immediately; poolinitSqlapplies on the next node spawn.
3.2 Worked example: 64 vCPU / 256 GiB server
| Consumer | CPU | Memory |
|---|---|---|
Manager JVM (JAVA_OPTS=-Xmx2g) | 2 | 2 GiB |
Pool bi: 5 read nodes + 1 write node, each threads=6, memory_limit='24GB' | 36 | 144 GiB budget |
Pool etl: 2 dual nodes, each threads=10, memory_limit='40GB' | 20 | 80 GiB budget |
| Headroom (page cache, spill, bursts) | 30 GiB |
Interactive pools can be modestly oversubscribed on CPU (not every node is busy at once); keep the sum of memory_limit under total RAM minus headroom.
3.3 The knob table
| What | Knob | Default |
|---|---|---|
| Node memory | SET memory_limit in db/pool initSql | ~80% of host RAM per node (unbounded in practice): always set it |
| Node threads | SET threads in db/pool initSql | all host cores per node: always set it |
| Node spill dir | SET temp_directory in initSql | DuckDB default |
| Manager heap | JAVA_OPTS=-Xmx2g (hard rule: never more than 2g) | none (JVM default) |
| Arrow allocator | -Darrow.allocation.manager.type=Unsafe | already set by the start scripts; required |
| Per-node concurrency cap | maxConcurrentPerNode on pool/create | 0 = unbounded |
| Total local nodes | QOD_MAX_NODES_TOTAL | 50 |
| Autoscale band | minNodes/maxNodes per pool; QOD_AUTOSCALE_HARD_CAP | band unset; cap 16 |
| Statement timeout | none globally; stmtTimeoutMs on scoped PATs only | none |
| Row caps | QOD_MCP_MAX_ROWS (MCP), QOD_CATALOG_PREVIEW_MAX_ROWS (UI preview), maxRows on PATs | 500 / 1000 / none |
| Edge session TTL | QOD_SESSION_TTL_SEC | 3600 |
| UI session idle TTL | QOD_SESSION_IDLE_TTL_SEC | 28800 |
| Telemetry queue | QOD_TELEMETRY_JOURNAL_CAPACITY | 8192 |
4. Creating tenants, databases, and pools
The objects nest as follows; sections 4.1 to 4.4 create one level each:
All control-plane commands need an authenticated session; qod auth login mints one, prompts for the password, and stores it in the CLI profile:
qod auth login --username [email protected]
4.1 Create the tenant
qod tenant create acme
4.2 Create a database (DuckLake kind, data on rustfs)
Bring-your-own-prefix form (explicit rustfs path and credentials):
qod database create --tenant acme --name sales --kind ducklake \
--data-path s3://acme-sales/ducklake \
--object-store s3_region=us-east-1 \
--object-store s3_access_key_id=... \
--object-store s3_secret_access_key=... \
--object-store s3_endpoint=rustfs.internal:9000 \
--object-store s3_url_style=path
Or, with managed storage enabled (section 0), let QoD allocate the prefix:
qod database create --tenant acme --name sales --kind ducklake --managed-storage
The server composes the full name as acme_sales. Postgres metastore keys can be omitted: they resolve from the manager's default metastore (QOD_PG_*) at node spawn time.
4.3 Create a pool
# 1 write node + 2 read nodes, with an autoscale band 3..6
qod pool create --tenant acme --db acme_sales --pool bi --size 3 \
--writeonly 1 --readonly 2 --min-nodes 3 --max-nodes 6
Day-2 pool operations: qod pool scale, qod pool suspend / qod pool resume (scale to zero and back; a suspended pool auto-wakes on the first statement), qod pool stop, qod pool delete, qod pool set-autoscale (change or clear the band).
4.4 Grant users access
Do it in SQL. Put the grants in a script and run it through the FlightSQL edge with qod sql --file (superuser or tenant-admin session):
-- grants.sql: one analyst role, one user, read access plus pool connectivity
CREATE ROLE analyst;
GRANT SELECT ON acme_sales.main.* TO ROLE analyst;
-- ROLES / GROUPS name exactly what alice gets. Left out, they default to the
-- built-ins qod_all_tables / qod_all_pools: full access to every table and pool.
CREATE USER alice PASSWORD 'Temp123!' ROLES analyst GROUPS qod_no_pools;
ALTER USER alice REQUIRE PASSWORD CHANGE;
-- Do not forget pool access: without it the user cannot reach the pool at all
GRANT CONNECT ON POOL bi TO USER alice;
-- Verify
SHOW GRANTS FOR USER alice;
qod sql --superuser --tenant acme --pool bi --file grants.sql
For team-scale management, attach roles to groups instead of users (qod group create, ALTER GROUP analysts ADD USER alice, qod pool permission grant for group-level pool access); the effect is identical, and IdP-driven SCIM group sync (section 6.4) plugs into the same groups.
5. Administering the database with SQL
With QOD_SQL_ADMIN_ENABLED=true (the default), a superuser or tenant-admin FlightSQL session can run an admin SQL dialect directly at the edge; it drives the same RBAC state as the REST API and CLI. Keep admin statements in versioned .sql scripts and run them fail-fast with:
qod sql --superuser --tenant acme --pool bi --file admin.sql
The dialect:
-- Users
CREATE USER alice PASSWORD 'secret';
CREATE USER ops PASSWORD 'secret' ADMIN;
CREATE USER bob PASSWORD 'secret' ROLES analyst GROUPS qod_no_pools;
ALTER USER alice PASSWORD 'newsecret';
ALTER USER alice REQUIRE PASSWORD CHANGE;
ALTER USER alice DISABLE;
DROP USER IF EXISTS alice;
-- Roles, groups, membership
CREATE ROLE analyst;
GRANT ROLE analyst TO USER alice;
ALTER GROUP finance ADD USER alice;
-- Table grants
GRANT SELECT ON acme_sales.main.orders TO ROLE analyst;
REVOKE ALL ON acme_sales.main.orders FROM ROLE analyst;
-- Row-level and column-level policies
CREATE ROW POLICY ON acme_sales.main.orders FOR ROLE analyst
USING (region = ${tenantId} OR owner = ${user});
CREATE COLUMN POLICY ON acme_sales.main.customers COLUMN email FOR ROLE analyst
MASK USING (SHA256(CAST(email AS VARCHAR)));
-- Pool connectivity
GRANT CONNECT ON POOL bi TO USER alice;
-- Introspection
SHOW USERS;
SHOW ROLES;
SHOW GRANTS FOR USER alice;
Notes worth passing to the DBA:
- Admin SQL is available on the FlightSQL wire only (not over MCP or REST previews); any ADBC/JDBC client works, e.g.
scripts/adbc.sh. - Admin statements commit immediately; they are not covered by a surrounding
BEGIN. SHOW USERS/SHOW ROLES/SHOW GRANTSshadow real tables with those names; quote the identifier (SHOW "users") to reach the table instead.- Password literals are redacted from statement history and logs.
- Ordinary data DDL/DML (CREATE TABLE, INSERT, etc.) is plain DuckDB SQL routed to the pool's write-capable nodes, governed by the same grants.
6. Authentication: Google Cloud, Azure, AWS, internal IdP, or database auth
QoD has two independent auth planes, configured separately:
| Plane | What it protects | Config block |
|---|---|---|
| FlightSQL data plane | SQL clients on :31338 (JDBC/ADBC, BI tools) and DuckDB clients on the native Quack front door :9494 (same handshake and providers) | quack-flightsql.auth.* (QOD_AUTH_*) |
| Management plane | Admin UI + REST on :20900 | quack-on-demand.auth.management.* (QOD_MGMT_*) |
A common production posture: admin UI on corporate SSO, SQL clients on database passwords or bearer JWTs, with database auth kept as break-glass.
6.1 Database auth (built-in, default on)
Bcrypt-hashed users in the qodstate_user table of the control-plane Postgres. On by default (QOD_AUTH_DB_ENABLED=true); nothing to configure beyond the admin seed:
export [email protected] # default: [email protected],admin
export QOD_ADMIN_PASSWORD='<strong password>' # default: admin. Rotate before go-live.
The admin row is re-seeded at every boot, so changing the env var and restarting rotates the credential. Create users with qod user create --tenant acme --username alice --password ... --role analyst --group qod_no_pools, via REST /api/user/create, or via SQL CREATE USER (section 5). Name the roles and groups explicitly: left out, they default to the built-ins qod_all_tables / qod_all_pools (full access to every table and pool of the tenant); see Built-in roles and groups. --kind admin (default user) grants management rights only. Optional account lockout: QOD_AUTH_LOCKOUT_ENABLED=true (requires SMTP: QOD_SMTP_HOST etc., plus QOD_PUBLIC_BASE_URL for the reset link).