Evidence.dev and Rill are both BI-as-code tools built on DuckDB. You define dashboards and metrics in SQL and YAML or Markdown, keep them in version control, and serve them as fast analytics interfaces. They solve overlapping problems in different ways, so this guide covers both and helps you pick, then walks through hardened deployments of each on a RamNode VPS.
Choosing between them
| Evidence.dev | Rill | |
|---|---|---|
| Output | Static site generated at build time | Live interactive dashboard server |
| Authoring | Markdown pages with embedded SQL | YAML metrics layer plus SQL models |
| Best for | Polished, narrative reports and data apps | Fast exploratory, time-series and event analytics |
| Runtime on the VPS | None after build; just static files | A long-running process (the Rill binary) |
| Self-host story | First-class; serve the build anywhere | Possible, but the multi-user product is Rill Cloud |
| Refresh model | Rebuild on a schedule or on data change | Queries run live against the embedded engine |
The practical split: if you want versioned, shareable reports that are cheap to host and safe to expose, Evidence is the cleaner fit because it produces plain static HTML with no live database connection. If you want interactive slice-and-dice over event or time-series data with a metrics layer, Rill is stronger, but be aware that self-hosting Rill means running Rill Developer, which is an editable authoring tool rather than a locked-down multi-user viewer. The vendor's intended path for sharing dashboards with others is Rill Cloud. Lock a self-hosted Rill down hard, as described in its section below.
Prerequisites (both)
A RamNode KVM VPS with 2 GB RAM and 2 vCPU handles small to mid datasets. DuckDB is memory-hungry on large aggregations, so size up for big data. Evidence raises Node's memory limit to 4096 MB during builds, so very large projects benefit from more RAM at build time.
Both sections assume Ubuntu 24.04 LTS, a non-root sudo user, and a DNS A record pointing at the VPS (reports.example.com for Evidence, rill.example.com for Rill) so the proxy can issue certificates.
Shared server preparation and hardening
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo apt update && sudo apt -y upgradeHarden SSH (PermitRootLogin no, PasswordAuthentication no in /etc/ssh/sshd_config, then restart ssh once your key is set). Configure the firewall to expose only SSH and the web ports:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableEnable unattended security upgrades:
sudo apt -y install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgradesInstall Caddy now, since both deployments use it for TLS:
sudo apt -y install debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt -y install caddyEvidence generates a static site at build time. It runs your SQL once during the build, pre-renders every page to HTML, and produces a build directory you serve as plain files. Nothing queries your database when a visitor loads a page, which makes the served site fast and safe to expose.
A1. Install Node and the project
sudo apt -y install nodejs npm
node --version # confirm 18+; install a newer Node via nodesource if your repo is oldScaffold a project (or clone your existing Evidence repo):
cd ~
npx degit evidence-dev/template my-reports
cd my-reports
npm installPut your data source credentials in environment variables rather than committing them. Evidence reads them as EVIDENCE_SOURCE__<source>__<option>. For a DuckDB or file-based source there may be no secret at all; for a remote warehouse, set them in the build environment only.
A2. Build the static site
npm run sources # pull and cache source data
npm run build:strict # build, failing on any broken query or componentUse build:strict for production. It fails the build if any SQL query errors or any component renders an error state, so you never publish a broken report. The output lands in ./build. Stage it where the web server will read it:
sudo install -d /var/www/reports
sudo rsync -a --delete build/ /var/www/reports/A3. Serve and add TLS
Because the artifact is static files, Caddy serves them directly with automatic TLS. Note that Evidence uses .arrow files for data loading, so make sure your server does not block unknown file types (Caddy does not, which is one reason to prefer it here over a hand-rolled nginx config).
/etc/caddy/Caddyfile (add this block):
reports.example.com {
root * /var/www/reports
encode gzip
file_server
}sudo systemctl reload caddyIf these reports are not meant to be public, add a basic-auth block (see the Rill section for the syntax) or restrict port 443 to known IPs at the firewall. A static site has no login of its own, so access control has to live at the proxy.
A4. Scheduled rebuilds
Static output is only as fresh as your last build, so rebuild on a schedule. Create /opt/evidence-rebuild.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /home/deploy/my-reports
git pull --ff-only || true
npm ci
npm run sources
npm run build:strict
rsync -a --delete build/ /var/www/reports/sudo chmod 700 /opt/evidence-rebuild.shDrive it with a systemd timer (for example hourly or nightly, matching how often your data changes). Building into a temporary directory and only swapping into /var/www/reports on success avoids serving a half-built site. The script above does the build in the project tree and only rsyncs after a successful build:strict, which gives you that safety.
A5. Evidence backups
Evidence is reproducible from source, so the things to back up are the project repository (already in Git if you follow good practice) and any source data files that are not regenerated elsewhere. The build directory is disposable since you can regenerate it. Keep the repo in a remote Git host and you have already covered most of it.
Rill runs as a long-lived process serving an interactive UI on port 8080, with DuckDB embedded as the default OLAP engine. Self-hosting runs Rill Developer, which is a full authoring environment, so the security posture matters more than with a static site.
B1. Install Rill
curl https://rill.sh | shTo pin a version instead of taking the latest:
curl https://rill.sh | sh -s -- --version <version_number>Verify with rill version. Scaffold or clone a project:
cd ~
rill init my-rill-projectB2. Run Rill headless behind a service
On a headless VPS, rill start tries to open a browser and logs a harmless error when it cannot. Pass --no-open to suppress that. Run it under systemd as an unprivileged user, bound to localhost so only the proxy reaches it.
sudo useradd --system --create-home --shell /usr/sbin/nologin rillsvc
sudo cp -r ~/my-rill-project /home/rillsvc/my-rill-project
sudo chown -R rillsvc:rillsvc /home/rillsvc/my-rill-project
sudo cp "$(command -v rill)" /usr/local/bin/rill/etc/systemd/system/rill.service:
[Unit]
Description=Rill Developer
After=network.target
[Service]
User=rillsvc
Group=rillsvc
WorkingDirectory=/home/rillsvc/my-rill-project
ExecStart=/usr/local/bin/rill start --no-open --no-ui=false /home/rillsvc/my-rill-project
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/home/rillsvc/my-rill-project
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now rillRill now listens on 127.0.0.1:9009 for its runtime and serves the UI on 127.0.0.1:8080. Confirm the exact local port with journalctl -u rill -n 20, then point the proxy at it.
B3. Reverse proxy, TLS, and locking it down
This step is mandatory, not optional. A self-hosted Rill Developer UI lets anyone who reaches it edit models and dashboards, so it must never be open. Add basic auth at the proxy and consider an IP allowlist on top.
Generate a password hash:
caddy hash-password --plaintext 'your-rill-password'/etc/caddy/Caddyfile (add this block):
rill.example.com {
encode gzip
basic_auth {
analyst PASTE_THE_BCRYPT_HASH_HERE
}
reverse_proxy 127.0.0.1:8080
}sudo systemctl reload caddyFor anything beyond a single trusted operator, restrict port 443 to known IPs at the firewall as well. If you need genuine multi-user access with per-user permissions, that is what Rill Cloud provides; the self-hosted route is best treated as a single-tenant internal tool.
B4. Rill backups
Rill projects are code, so the project directory (YAML configs, SQL models, rill.yaml) belongs in Git and that covers most of it. The embedded DuckDB file under the project directory holds ingested data and can be large; back it up with the project if it is your source of truth, or exclude it if you can re-ingest from upstream. A simple nightly archive:
sudo tar czf /var/backups/rill-$(date +%F).tar.gz -C /home/rillsvc my-rill-projectPush it off the VPS to RamNode object storage or another remote target.
Monitoring and alerting (both)
For Evidence, the signal that matters is build success. A failed build:strict in your scheduled rebuild should page you, because it means reports stopped refreshing even though the old static site keeps serving. Have the rebuild script exit non-zero on failure (it does, via set -e) and have your scheduler surface that.
For Rill, watch the systemd unit and the process memory. systemctl status rill and journalctl -u rill tell you if it crashed or is restarting, and DuckDB memory pressure on large queries is the usual cause of trouble.
On alert delivery, account for RamNode's outbound mail policy. RamNode blocks or throttles direct SMTP on port 25 by default, so alerts built on a local mailer or a raw port-25 connection will fail without warning. Send notifications through a transactional email API over HTTPS, a chat webhook, or an authenticated relay on port 587 instead of relying on the VPS to deliver mail directly.
Upgrades
For Evidence, bump the dependencies in the project (npm update or your usual workflow), rerun build:strict, and redeploy. Since the runtime is just static files, an upgrade is really a rebuild.
For Rill, rerun the install script to get the latest binary, replace /usr/local/bin/rill, and restart the service:
curl https://rill.sh | sh
sudo cp "$(command -v rill)" /usr/local/bin/rill
sudo systemctl restart rillIf rill was previously installed via Homebrew, the brew binary can take precedence on PATH; remove it so your updated binary is the one that runs.
Troubleshooting
If an Evidence build fails on a query that returns no rows, remember that an empty result is not itself a failure, but a component expecting rows will error under build:strict. Guard those components with an {#if} block.
If the Evidence site serves but charts are blank, confirm the web server is delivering .arrow files and not blocking them by extension. Caddy serves them fine by default.
If Rill starts but the UI is unreachable, you are likely hitting the headless browser-open behavior or a localhost binding. The server is still running; connect through the reverse proxy rather than expecting Rill to open a browser on the VPS.
If Rill queries are slow or the process is killed, it is almost always DuckDB memory pressure on a large aggregation. Move to a larger RamNode plan or push heavy data into an external OLAP source rather than the embedded engine.
