Going to production

TLS, reverse proxies, the licensed domain, service managers, backups, Content-Security-Policy and isolation headers, and a production checklist for SQLly Embedded.

On localhost the defaults just work. In production, put the bridge behind TLS, bind your license to your domain, keep the management API private, and run everything as services. This page has the settings, configs and checklist for that.

Recommended layout

  • One host or private network for your app and the bridge. The management API (127.0.0.1:8445) should only be reachable by your app.
  • A reverse proxy (nginx, Caddy, IIS, a cloud load balancer) terminates TLS and forwards WebSocket upgrades on a path such as /sqlly/proxy to the bridge's --bind listener.
  • The same proxy serves your app, including the SQLly page and web/ bundle, so everything is same-origin under your licensed domain.
  • The bridge needs outbound HTTPS to sqlly.app (connected licenses only) and network access to your databases. Browsers never reach your databases or the engine.

Reverse proxy

nginx
location /sqlly/proxy {
    proxy_pass http://127.0.0.1:8444/proxy;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header Origin $http_origin;      # the bridge checks it against your licensed domain
    proxy_read_timeout 1h;                     # long-running queries and idle editors
    proxy_send_timeout 1h;
}
caddy
portal.example.com {
    handle /sqlly/proxy {
        rewrite * /proxy
        reverse_proxy 127.0.0.1:8444
    }
    reverse_proxy 127.0.0.1:3000   # your app
}

Then start the bridge with --public-url wss://portal.example.com/sqlly/proxy so every launch token tells SQLly where to connect. To skip the proxy, give the bridge --tls-cert and --tls-key and it serves wss:// itself.

Run it as a service

systemd: /etc/systemd/system/sqlly-embedded.service
[Unit]
Description=SQLly Embedded bridge
After=network-online.target
Wants=network-online.target

[Service]
User=sqlly
EnvironmentFile=/etc/sqlly-embedded.env
ExecStart=/opt/sqlly-embedded/bin/sqlly-bridge embedded
Restart=on-failure
RestartSec=10
# systemd's default SIGTERM is a clean shutdown: the bridge releases its
# instance slot on sqlly.app at once.
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
/etc/sqlly-embedded.env (chmod 600)
SQLLY_EMBEDDED_LICENSE_KEY=sqe_…
SQLLY_EMBEDDED_MANAGE_TOKEN=…
SQLLY_EMBEDDED_DOMAIN=portal.example.com
SQLLY_EMBEDDED_PUBLIC_URL=wss://portal.example.com/sqlly/proxy
SQLLY_EMBEDDED_BIND=127.0.0.1:8444
SQLLY_EMBEDDED_MANAGE_BIND=127.0.0.1:8445
SQLLY_EMBEDDED_STATE_DIR=/var/lib/sqlly-embedded
SQLLY_EMBEDDED_ALLOW_LOCALHOST_ORIGINS=false

On macOS use a launchd plist with the same environment. On Windows run sqlly-bridge.exe embedded under a service wrapper or Task Scheduler. In containers, mount the state directory as a volume. The bridge exits with a clear message if its license can't be verified at startup, so a restart policy keeps retrying until sqlly.app is reachable.

Stop it with a normal stop signal. The bridge shuts down cleanly on SIGTERM (what systemd, launchd and docker stop send), SIGINT (Ctrl-C), or the console window closing on Windows: it closes sessions and releases its instance slot on sqlly.app at once, retrying for a few seconds if sqlly.app doesn't answer. A hard kill (SIGKILL, a crash, power loss) leaves the slot held until it goes stale 40 minutes later, and a replacement bridge isn't admitted until then. Bridges from before this change released the slot only on SIGINT; if you run one, add KillSignal=SIGINT to the unit or use STOPSIGNAL SIGINT in Docker.

Every bridge setting

Each flag has an environment variable, so a service unit or .env file can hold the whole configuration.

FlagEnvironmentDefaultMeaning
--license-keySQLLY_EMBEDDED_LICENSE_KEYnoneConnected license key from your Embedded page.
--license-fileSQLLY_EMBEDDED_LICENSE_FILEnoneOffline license file. Never contacts sqlly.app. Give at most one of key and file; with neither, a linked key is used, else the bridge starts unlicensed.
--domainSQLLY_EMBEDDED_DOMAINnoneBinds an unbound license on the first check-in; refuses to start if the license is bound elsewhere.
--bindSQLLY_EMBEDDED_BIND0.0.0.0:8444Browser WebSocket listener (path /proxy). Use loopback behind a reverse proxy.
--public-urlSQLLY_EMBEDDED_PUBLIC_URLws://localhost:<port>/proxyThe URL browsers use, returned with every launch token.
--manage-bindSQLLY_EMBEDDED_MANAGE_BIND127.0.0.1:8445Management API listener. Keep it private: it holds credentials.
--manage-tokenSQLLY_EMBEDDED_MANAGE_TOKENrequiredBearer token for the management API, at least 16 characters.
--tls-cert, --tls-keySQLLY_EMBEDDED_TLS_CERT, SQLLY_EMBEDDED_TLS_KEYnoneServe wss:// directly. Give both or neither.
--state-dirSQLLY_EMBEDDED_STATE_DIR./sqlly-embedded-stateProfiles, cached grant, linked key, per-session credential files. Created owner-only.
--engine-binarySQLLY_CLI_BINbeside the bridge, then PATHThe sqlly-cli the bridge runs per session.
--max-sessionsSQLLY_EMBEDDED_MAX_SESSIONS64Hard cap on concurrent engines on this host.
--allow-localhost-originsSQLLY_EMBEDDED_ALLOW_LOCALHOST_ORIGINStrueAccept localhost origins besides the licensed domain. Set false in production.
--token-ttl-secondsSQLLY_EMBEDDED_TOKEN_TTL_SECONDS600Default launch-token lifetime.
--store-urlSQLLY_STORE_URLhttps://sqlly.appWhere check-ins go. Leave it alone outside development.
--allow-file-enginesSQLLY_EMBEDDED_ALLOW_FILE_ENGINESfalseAccept profiles with file-based engines (SQLite, DuckDB). Off, they are refused with 400 invalid_profile. See File-based engines.
--query-logSQLLY_EMBEDDED_QUERY_LOGtrueKeep the query audit trail (GET /manage/queries, <state-dir>/queries.jsonl). false records nothing. See Query audit trail.
--query-log-max-entriesSQLLY_EMBEDDED_QUERY_LOG_MAX_ENTRIES20000Newest finished queries kept, in memory and on disk (minimum 100).

The state directory and the host

The state directory holds every profile with its passwords, the cached license grant, a linked install's key, the per-session credential files handed to engines, and the query audit trail. On Linux and macOS the bridge creates it owner-only (0700, files 0600).

On Windows the bridge cannot tighten permissions. The state directory inherits the ACL of its parent folder, and the bridge logs a warning at startup to say so. Put it under a folder only the service account can read, for example a dedicated directory under %ProgramData% with inheritance removed and an ACL granting the service account alone, and never under a shared, synced or user-browsable path.

File-based engines

File-based engines are off by default. A SQLite or DuckDB connection in a profile names a file on the bridge host. Its users can then read that file, create new ones, and, through the engine's own file functions, reach other files the service account can read. The bridge therefore refuses profiles, connection tests and launch tokens that involve a file-based engine with 400 invalid_profile and a problems[] entry explaining why, unless it runs with --allow-file-engines true. Enable it only for a dedicated, locked-down service account and a directory you intend users to reach. In proxy mode the engine additionally forbids DuckDB extension downloads and external access (file scans, COPY, ATTACH) for every DuckDB instance it opens.

Query audit trail

The bridge records every query and row edit its sessions run: the user and profile of the launch token, the connection (name, server, engine, database), the SQL text, when it started, how it ended and how long it took. Requests refused by a license problem are recorded as failed with the license message; whatever was running when a session closed is recorded as cancelled. Your app reads it with GET /manage/queries, newest first, filtered by user, profile, status or time.

The newest --query-log-max-entries entries live in memory and in <state-dir>/queries.jsonl (one JSON object per line, written owner-only, compacted automatically), so the trail survives restarts. The SQL text is stored on the bridge host in clear. Keep the state directory as private as the profiles it already holds, and set --query-log false if your policy forbids keeping query text at all.

The engine's own log

Every sqlly-cli engine writes a request/response correlation log, including the full SQL text of every request, to its data directory: ~/.local/share/sqlly/sqlly-engine.log (or $XDG_DATA_HOME/sqlly/sqlly-engine.log) on Linux, ~/Library/Application Support/SQLLY/sqlly-engine.log on macOS, %APPDATA%\SQLLY\sqlly-engine.log on Windows, created owner-only. Passwords, tokens and secret references are scrubbed before a line is written; ids, servers, usernames and SQL are kept. Set SQLLY_ENGINE_LOG=<path> in the bridge's environment (its engines inherit it) to move the log somewhere you control, or SQLLY_ENGINE_LOG=/dev/null (NUL on Windows) to keep nothing. The log is trimmed to its last lines only when an engine exits cleanly.

AI provider settings

The engine picks up AI provider configuration from its environment (SQLLY_LLM_PROVIDER, SQLLY_EMBEDDING_PROVIDER and the related model and key variables). Do not set them on the bridge host unless you want your end users' IntelliSense and AI features to spend against that account.

Safety flags

A profile's only_allow_select (on a client, project or environment label) and a connection's safety.block_all_but_select are enforced by the engine on the bridge host: a statement that is not SELECT-only for the connection's engine, and every row edit, is refused with "this connection is read-only by policy" before it reaches the database, whatever the browser claims. The other safety flags (confirmation dialogs, the rollback wrap, unbounded UPDATE/DELETE protection) are behaviour of the SQLly client in the user's browser, not a server-side control. Database permissions remain the real control: give proxied logins only what their users should be able to do.

Sizing

Each open SQLly tab runs one sqlly-cli engine process with its own database connections. Memory follows what people do: result sets are streamed and spilled to disk rather than held whole, but large exports and many concurrent tabs add up. Size the host for your peak concurrent tabs (seats × tabs per seat at most), and use --max-sessions as a backstop. Engines are started on demand and exit when their tab closes.

Operations

  • Back up the state directory. It holds every profile with its credentials and a linked install's key. Treat backups as secrets.
  • Watch the log for embedded license state: every license transition is logged with its state name. Poll GET /manage/status from your monitoring, and use GET /healthz (no token) for liveness.
  • Keep the clock right. Grants must be issued within 5 minutes of the bridge's clock, so run NTP.
  • Upgrade together. Replace bin/ and web/ from the same package version. Restarting the bridge closes open tabs, and users reconnect by reloading.
  • Rotate the manage token by restarting the bridge and your app with the new value. Launch tokens already minted keep working until they expire.
  • Moving domains? Change it on your Embedded page and restart the bridge with the new --domain. Offline licenses need a new file, because the domain is inside it.

Production checklist

  • TLS in front of the browser listener, and --public-url set to the wss:// address.
  • SQLLY_EMBEDDED_DOMAIN set and the license bound to it; --allow-localhost-origins=false.
  • Management API on loopback or a private network; manage token only in server-side secrets.
  • Launch tokens handed to the page through the SQLLY_PROXY_URL / SQLLY_PROXY_TOKEN globals, never in a page URL (?token= ends up in history, referrers and web-server logs).
  • --allow-file-engines left off unless a locked-down service account and directory were set up for it.
  • State directory readable by the service account only (owner-only on Linux/macOS; an explicit ACL on Windows); --query-log and SQLLY_ENGINE_LOG set according to your policy on keeping query text.
  • No SQLLY_LLM_PROVIDER / SQLLY_EMBEDDING_PROVIDER in the bridge's environment unless end users should drive that spend.
  • Bridge and app running as services under an unprivileged user, restart on failure, graceful stop.
  • State directory on persistent storage, owner-only, backed up.
  • The SQLly page and web/ served with COOP/COEP, application/wasm, and a CSP that allows the bridge origin.
  • Outbound HTTPS to sqlly.app allowed (connected licenses), NTP on.
  • Monitoring on /manage/status: alert when license.state isn't ok.
  • The sample app's default admin password changed, if you deploy the sample app at all. It refuses to bind beyond loopback until you do.