BI as Code
    DuckDB

    Deploy Evidence.dev and Rill on a VPS

    Self-host Evidence.dev static BI reports and Rill interactive dashboards on a RamNode VPS — DuckDB-backed analytics, Caddy TLS, hardening, and backups.

    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.devRill
    OutputStatic site generated at build timeLive interactive dashboard server
    AuthoringMarkdown pages with embedded SQLYAML metrics layer plus SQL models
    Best forPolished, narrative reports and data appsFast exploratory, time-series and event analytics
    Runtime on the VPSNone after build; just static filesA long-running process (the Rill binary)
    Self-host storyFirst-class; serve the build anywherePossible, but the multi-user product is Rill Cloud
    Refresh modelRebuild on a schedule or on data changeQueries 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

    shell
    sudo adduser deploy
    sudo usermod -aG sudo deploy
    sudo apt update && sudo apt -y upgrade

    Harden 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:

    shell
    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 enable

    Enable unattended security upgrades:

    shell
    sudo apt -y install unattended-upgrades
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    Install Caddy now, since both deployments use it for TLS:

    shell
    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 caddy

    Evidence 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

    shell
    sudo apt -y install nodejs npm
    node --version   # confirm 18+; install a newer Node via nodesource if your repo is old

    Scaffold a project (or clone your existing Evidence repo):

    shell
    cd ~
    npx degit evidence-dev/template my-reports
    cd my-reports
    npm install

    Put 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

    shell
    npm run sources        # pull and cache source data
    npm run build:strict   # build, failing on any broken query or component

    Use 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:

    shell
    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):

    shell
    reports.example.com {
        root * /var/www/reports
        encode gzip
        file_server
    }
    shell
    sudo systemctl reload caddy

    If 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:

    shell
    #!/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/
    shell
    sudo chmod 700 /opt/evidence-rebuild.sh

    Drive 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

    shell
    curl https://rill.sh | sh

    To pin a version instead of taking the latest:

    shell
    curl https://rill.sh | sh -s -- --version <version_number>

    Verify with rill version. Scaffold or clone a project:

    shell
    cd ~
    rill init my-rill-project

    B2. 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.

    shell
    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:

    shell
    [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.target
    shell
    sudo systemctl daemon-reload
    sudo systemctl enable --now rill

    Rill 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:

    shell
    caddy hash-password --plaintext 'your-rill-password'

    /etc/caddy/Caddyfile (add this block):

    shell
    rill.example.com {
        encode gzip
        basic_auth {
            analyst PASTE_THE_BCRYPT_HASH_HERE
        }
        reverse_proxy 127.0.0.1:8080
    }
    shell
    sudo systemctl reload caddy

    For 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:

    shell
    sudo tar czf /var/backups/rill-$(date +%F).tar.gz -C /home/rillsvc my-rill-project

    Push 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:

    shell
    curl https://rill.sh | sh
    sudo cp "$(command -v rill)" /usr/local/bin/rill
    sudo systemctl restart rill

    If 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.