The Problem with Traditional BI
Business intelligence dashboards built in Tableau, Power BI, or Looker solve the discovery problem for non-technical users but create a maintenance nightmare for data engineers. Every report is a GUI artifact: a collection of drag-and-drop configurations stored in a proprietary format that cannot be version-controlled, code-reviewed, or tested. When a metric definition changes, someone has to manually find every report that references it and update each one by hand. When a dashboard breaks, the error is a blank chart — no stack trace, no line number, no test failure.
Evidence.dev takes the opposite approach: analytics reports as code. You write SQL queries and Markdown in .md files, embed built-in chart and table components, and Evidence compiles the whole thing into a fast, self-contained static website. Reports live in Git, get reviewed in pull requests, and deploy through CI/CD pipelines exactly like application code. Combined with dbt for transformation logic, Evidence gives analytics engineers a fully code-driven stack from raw source to published report — no GUI required at any layer.
SQL-First
Queries are plain SQL in fenced code blocks. Evidence executes them at build time against your data source and passes results to chart components as typed arrays.
Static Output
Build output is a fully static site — no backend, no database at runtime. Data is baked into the HTML/JS bundle. Serve it from S3, Cloudflare Pages, or Nginx.
Version Control
Every report, query, and metric definition is a text file in Git. PRs show exactly which SQL changed. CI validates that reports build before merge.
Installation and Project Structure
Evidence requires Node.js 18+ and npm. The create-evidence scaffolder creates a minimal project with a dev server, a sample report, and a DuckDB connector pre-configured. The dev server watches .md files and rebuilds pages on save, giving a browser preview within a few hundred milliseconds of every edit.
# Bootstrap a new Evidence project
npm create evidence@latest my-analytics
cd my-analytics
npm install
npm run dev
# → http://localhost:3000
# Project layout after scaffolding:
my-analytics/
├── pages/ # One .md file = one report page
│ ├── index.md # Homepage (required)
│ ├── revenue/
│ │ ├── overview.md
│ │ └── by-channel.md
│ └── operations/
│ └── pipeline-health.md
├── sources/ # Data source connection configs
│ ├── warehouse/
│ │ └── connection.yaml # BigQuery / Snowflake / DuckDB connection
│ └── local_files/
│ └── connection.yaml # DuckDB reading local Parquet/CSV
├── components/ # Custom Svelte components (optional)
│ └── KpiCard.svelte
├── static/ # Static assets (images, logos)
├── evidence.config.yaml # Project-level config
└── package.json# evidence.config.yaml
# Controls layout, interactivity, and deployment settings
title: "Analytics Platform"
description: "Internal data reports for DataOps team"
# Show or hide the query editor in the rendered report
# Set to false for production; true for analyst self-service
showQueryEditor: false
# Controls which pages appear in the sidebar navigation
# 'auto' discovers all .md files; 'manual' reads from evidence.sidebar.yaml
sidebar: auto
# Deployment target for 'npm run build'
# 'static' = plain HTML/JS/CSS; 'netlify', 'cloudflare', 'github-pages'
deployment:
target: static
basePath: /reports # if serving from a sub-pathNote
.md files are processed by a custom Markdown plugin that extracts SQL code blocks, executes them against the configured data source at build time, and injects the results as typed JavaScript objects available to the component expressions on that page.Data Source Connectors
Evidence supports a growing list of connectors installed as npm packages. The default connector is DuckDB, which lets you query local Parquet, CSV, and JSON files without any external database. For production deployments against a cloud warehouse, you install the relevant connector package and add credentials to sources/. DuckDB's columnar engine makes Evidence extremely fast for local analytics and prototyping — a 10GB Parquet file queries in seconds without loading it into memory.
# Install connector packages for your warehouse
npm install @evidence-dev/bigquery
npm install @evidence-dev/snowflake
npm install @evidence-dev/postgres
npm install @evidence-dev/databricks
npm install @evidence-dev/duckdb # default, usually pre-installed
npm install @evidence-dev/mysql
npm install @evidence-dev/trino
npm install @evidence-dev/csv # read CSV files via DuckDB
# sources/warehouse/connection.yaml — BigQuery connector
connector: bigquery
project_id: my-gcp-project
# Credentials from EVIDENCE_BIGQUERY_CREDENTIALS env var (base64 JSON service account)
# or from application default credentials (gcloud auth application-default login)
---
# sources/warehouse/connection.yaml — Snowflake connector
connector: snowflake
account: myorg-myaccount
database: ANALYTICS
warehouse: COMPUTE_WH
schema: DBT_PROD
# Credentials from env: EVIDENCE_SNOWFLAKE_USERNAME, EVIDENCE_SNOWFLAKE_PASSWORD
---
# sources/local_files/connection.yaml — DuckDB reading Parquet from S3
connector: duckdb
# DuckDB SQL executed before any query to configure extensions and secrets
initSQL: |
INSTALL httpfs;
LOAD httpfs;
SET s3_region = 'eu-west-1';
CREATE SECRET aws_s3 (
TYPE S3,
PROVIDER CREDENTIAL_CHAIN
);# sources/local_files/connection.yaml — multiple file sources via DuckDB
connector: duckdb
initSQL: |
-- Create views over Parquet files exported from dbt
CREATE OR REPLACE VIEW orders AS
SELECT * FROM read_parquet('data/orders_*.parquet');
CREATE OR REPLACE VIEW customers AS
SELECT * FROM read_parquet('data/customers.parquet');
CREATE OR REPLACE VIEW events AS
SELECT * FROM read_parquet('data/events/*.parquet', hive_partitioning=true);Writing Your First Report
An Evidence page is a Markdown file with SQL code blocks and component tags. The SQL blocks run at build time and make their result available as a named variable. Component tags like <BarChart> and <DataTable> take those variables as props. Between components you write regular Markdown prose — headings, paragraphs, callouts. The result looks like a technical document with live data embedded in it, not a traditional dashboard.
---
title: Revenue Overview
description: Daily and monthly revenue trends with channel breakdown
---
# Revenue Overview
This report covers the last 90 days of order revenue across all acquisition channels.
Data refreshes daily at 06:00 UTC from the `fct_orders` dbt model.
```sql daily_revenue
-- Named 'daily_revenue' — available as {daily_revenue} on this page
SELECT
ordered_at::date AS date,
acquisition_channel,
SUM(gross_revenue_usd) AS gross_revenue,
SUM(net_revenue_usd) AS net_revenue,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT customer_id) AS unique_customers,
ROUND(SUM(gross_revenue_usd) / NULLIF(COUNT(DISTINCT order_id), 0), 2) AS revenue_per_order
FROM warehouse.fct_orders
WHERE ordered_at >= CURRENT_DATE - INTERVAL 90 DAY
GROUP BY 1, 2
ORDER BY 1 DESC, 3 DESC
```
```sql summary_kpis
SELECT
SUM(gross_revenue_usd) AS total_revenue,
COUNT(DISTINCT order_id) AS total_orders,
COUNT(DISTINCT customer_id) AS unique_customers,
ROUND(AVG(gross_revenue_usd), 2) AS avg_order_value,
SUM(gross_revenue_usd) - LAG(SUM(gross_revenue_usd))
OVER (ORDER BY DATE_TRUNC('month', ordered_at)) AS mom_change
FROM warehouse.fct_orders
WHERE ordered_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL 1 MONTH)
```
## Key Metrics (Last 30 Days)
<BigValue
data={summary_kpis}
value="total_revenue"
title="Gross Revenue"
fmt="usd0"
/>
<BigValue
data={summary_kpis}
value="total_orders"
title="Orders"
fmt="num0"
/>
<BigValue
data={summary_kpis}
value="avg_order_value"
title="Avg Order Value"
fmt="usd2"
/>
## Daily Revenue by Channel
<BarChart
data={daily_revenue}
x="date"
y="gross_revenue"
series="acquisition_channel"
type="stacked"
title="Gross Revenue by Acquisition Channel"
yFmt="usd0k"
/>
## Detail Table
<DataTable
data={daily_revenue}
rows={25}
search={true}
sortable={true}
>
<Column id="date" title="Date" fmt="date" />
<Column id="acquisition_channel" title="Channel" />
<Column id="gross_revenue" title="Gross Revenue" fmt="usd0" />
<Column id="order_count" title="Orders" fmt="num0" />
<Column id="revenue_per_order" title="Rev / Order" fmt="usd2" />
</DataTable>Built-In Chart and Table Components
Evidence ships with over 25 built-in components covering the full BI component palette: bar charts, line charts, area charts, scatter plots, bubble charts, heatmaps, maps, funnel charts, sankey diagrams, KPI tiles, data tables with inline sparklines, and more. All components accept a data prop pointing to a named query result, column name props specifying which fields map to axes and series, and format strings for value display.
```sql funnel_data
SELECT
stage,
stage_order,
COUNT(DISTINCT user_id) AS users,
ROUND(100.0 * COUNT(DISTINCT user_id) /
FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (ORDER BY stage_order), 1) AS conversion_pct
FROM warehouse.user_funnel_events
WHERE event_date >= CURRENT_DATE - INTERVAL 30 DAY
GROUP BY 1, 2
ORDER BY 2
```
<FunnelChart
data={funnel_data}
nameCol="stage"
valueCol="users"
title="30-Day Conversion Funnel"
/>
---
```sql cohort_retention
SELECT
cohort_month,
months_since_signup,
ROUND(100.0 * retained_users / cohort_size, 1) AS retention_rate
FROM warehouse.cohort_retention
WHERE cohort_month >= '2025-01-01'
```
<Heatmap
data={cohort_retention}
x="months_since_signup"
y="cohort_month"
value="retention_rate"
title="Monthly Cohort Retention (%)"
colorScale={["#1e3a5f", "#2563eb", "#93c5fd"]}
/>
---
```sql pipeline_runs
SELECT
pipeline_name,
run_date,
duration_seconds,
status,
rows_processed
FROM warehouse.pipeline_run_log
ORDER BY run_date DESC
LIMIT 500
```
<DataTable data={pipeline_runs} rows={20} sortable={true}>
<Column id="pipeline_name" title="Pipeline" />
<Column id="run_date" title="Run Date" fmt="datetime" />
<Column id="duration_seconds" title="Duration" fmt="num1" suffix="s" />
<Column id="rows_processed" title="Rows" fmt="num0" />
<Column id="status" title="Status" contentType="colorscale" />
</DataTable>Templated Pages and URL Parameters
Evidence supports parameterized reports through URL-driven filters and SvelteKit-style dynamic route files. A file named pages/customers/[customer_id].md generates one page per row in a specified query, enabling customer-level drill-down reports without a backend. The inputs component family provides dropdowns and date pickers that filter other queries on the same page through reactive variable binding.
# pages/customers/[customer_id].md
# Dynamic page — generates one page per customer_id at build time
```sql customers_list
-- This query drives page generation — one page per row
SELECT customer_id FROM warehouse.dim_customers WHERE is_active = true
```
```sql customer_detail
SELECT
c.customer_id,
c.company_name,
c.customer_segment,
c.first_order_at,
c.lifetime_orders,
c.lifetime_revenue_usd,
c.account_manager
FROM warehouse.dim_customers c
WHERE c.customer_id = '${params.customer_id}'
```
```sql customer_orders
SELECT
order_id,
ordered_at,
status,
gross_revenue_usd,
net_revenue_usd,
acquisition_channel
FROM warehouse.fct_orders
WHERE customer_id = '${params.customer_id}'
ORDER BY ordered_at DESC
LIMIT 100
```
# {customer_detail[0].company_name}
**Segment:** {customer_detail[0].customer_segment} |
**Account Manager:** {customer_detail[0].account_manager} |
**Customer Since:** {customer_detail[0].first_order_at}
<BigValue data={customer_detail} value="lifetime_revenue_usd" title="Lifetime Revenue" fmt="usd0" />
<BigValue data={customer_detail} value="lifetime_orders" title="Total Orders" fmt="num0" />
## Order History
<DataTable data={customer_orders} sortable={true} rows={20}>
<Column id="ordered_at" title="Date" fmt="date" />
<Column id="status" title="Status" />
<Column id="gross_revenue_usd" title="Revenue" fmt="usd2" />
<Column id="acquisition_channel" title="Channel" />
</DataTable># pages/revenue/by-channel.md
# Interactive filtering with Inputs components — no backend needed
```sql channels
SELECT DISTINCT acquisition_channel AS label, acquisition_channel AS value
FROM warehouse.fct_orders
ORDER BY 1
```
<Dropdown
data={channels}
name="selected_channel"
label="Acquisition Channel"
value="value"
title="label"
defaultValue="organic"
/>
<DateRange
name="date_range"
label="Date Range"
defaultValue="last_90_days"
/>
```sql filtered_revenue
SELECT
ordered_at::date AS date,
SUM(gross_revenue_usd) AS revenue,
COUNT(DISTINCT order_id) AS orders
FROM warehouse.fct_orders
WHERE acquisition_channel = '${inputs.selected_channel}'
AND ordered_at BETWEEN '${inputs.date_range.start}' AND '${inputs.date_range.end}'
GROUP BY 1
ORDER BY 1
```
<LineChart
data={filtered_revenue}
x="date"
y="revenue"
title="Revenue — {inputs.selected_channel}"
yFmt="usd0k"
/>Note
Dropdown and DateRange values filter JavaScript arrays in the browser. For large result sets, use SQL-level WHERE clauses driven by inputs variables to push filtering to the build step and keep bundle sizes manageable.dbt Integration and Semantic Layer
Evidence works naturally alongside dbt. In the most common pattern, dbt runs first (in CI or on a schedule), materializes mart-layer tables in the warehouse, and Evidence queries those tables directly. The dbt project provides tested, documented, version-controlled transformation logic; Evidence's build step then validates that every report query returns data with no runtime errors — making the combined pipeline testable end to end.
# Typical dbt + Evidence monorepo layout
data-platform/
├── dbt/ # dbt project
│ ├── models/
│ │ ├── staging/
│ │ ├── intermediate/
│ │ └── marts/
│ │ ├── fct_orders.sql # public mart models
│ │ ├── dim_customers.sql
│ │ └── agg_revenue_daily.sql
│ ├── dbt_project.yml
│ └── profiles.yml
│
└── evidence/ # Evidence project
├── sources/
│ └── warehouse/
│ └── connection.yaml # points at same warehouse as dbt prod target
├── pages/
│ ├── index.md
│ ├── revenue/
│ │ └── overview.md # queries dbt mart: fct_orders, agg_revenue_daily
│ └── customers/
│ └── [customer_id].md # queries dbt mart: dim_customers, fct_orders
└── evidence.config.yaml# .github/workflows/deploy-reports.yml
# CI/CD: dbt runs first, then Evidence builds and deploys
name: Deploy Analytics Reports
on:
push:
branches: [main]
schedule:
- cron: "0 6 * * *" # Daily refresh at 06:00 UTC
jobs:
dbt-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dbt
run: pip install dbt-bigquery==1.8.0
- name: dbt run
working-directory: dbt
env:
GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.GCP_SA_KEY }}
run: |
echo "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > /tmp/gcp-key.json
export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-key.json
dbt deps && dbt run --target prod --select "marts.*"
- name: dbt test
working-directory: dbt
run: dbt test --target prod --select "marts.*"
evidence-build:
needs: dbt-run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
working-directory: evidence
run: npm ci
- name: Build Evidence reports
working-directory: evidence
env:
EVIDENCE_BIGQUERY_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
EVIDENCE_BIGQUERY_PROJECT_ID: my-gcp-project
run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: analytics-reports
directory: evidence/buildCustom Svelte Components
When built-in components are not sufficient, Evidence lets you write custom Svelte components in the components/ directory and use them in any .md page without importing. Custom components receive query result rows as typed props. You have full access to Svelte reactivity, D3.js, or any npm-installable charting library — Evidence places no restrictions on what your components can render.
<!-- components/PipelineStatusBadge.svelte -->
<!-- Custom component: renders a status badge with color coding -->
<script>
export let status = "unknown";
export let duration_seconds = 0;
const colorMap = {
success: "bg-green-500/20 text-green-400 border-green-500/30",
failed: "bg-red-500/20 text-red-400 border-red-500/30",
running: "bg-blue-500/20 text-blue-400 border-blue-500/30",
skipped: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
$: colorClass = colorMap[status] || colorMap.skipped;
$: durationLabel = duration_seconds >= 60
? `${Math.floor(duration_seconds / 60)}m ${duration_seconds % 60}s`
: `${duration_seconds}s`;
</script>
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium {colorClass}">
{status}
{#if duration_seconds > 0}
<span class="opacity-60">({durationLabel})</span>
{/if}
</span># pages/operations/pipeline-health.md
# Using the custom PipelineStatusBadge component
```sql pipeline_status
SELECT
pipeline_name,
last_run_at,
last_run_status,
last_run_duration_seconds,
avg_duration_7d,
success_rate_7d
FROM warehouse.pipeline_monitoring
ORDER BY last_run_at DESC
```
## Pipeline Health Dashboard
<DataTable data={pipeline_status} rows={30} sortable={true}>
<Column id="pipeline_name" title="Pipeline" />
<Column id="last_run_at" title="Last Run" fmt="datetime" />
<Column id="last_run_status" title="Status" contentType="component">
<PipelineStatusBadge
status={row.last_run_status}
duration_seconds={row.last_run_duration_seconds}
/>
</Column>
<Column id="success_rate_7d" title="7d Success Rate" fmt="pct1" />
<Column id="avg_duration_7d" title="Avg Duration" fmt="num1" suffix="s" />
</DataTable>Deployment Options
Because npm run build produces a plain static site, Evidence deploys to any static hosting platform. For internal analytics with access control, the most common pattern is deploying to an S3 bucket or GCS bucket fronted by a load balancer with SSO authentication (Cloudflare Access, AWS Cognito, or Google IAP). Evidence Cloud is the hosted SaaS option: you connect your Git repo, add credentials, and Evidence runs scheduled builds and serves reports with built-in authentication.
# Dockerfile for self-hosted Evidence with Nginx + HTTP Basic Auth
# Build the Evidence site and serve it behind Nginx
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG EVIDENCE_SNOWFLAKE_USERNAME
ARG EVIDENCE_SNOWFLAKE_PASSWORD
ARG EVIDENCE_SNOWFLAKE_ACCOUNT
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
COPY .htpasswd /etc/nginx/.htpasswd
EXPOSE 80# nginx.conf — serve Evidence static site with basic auth
events {}
http {
include mime.types;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Basic auth for internal access
auth_basic "Analytics Platform";
auth_basic_user_file /etc/nginx/.htpasswd;
# SPA routing — fall back to index.html for client-side navigation
location / {
try_files $uri $uri.html $uri/ /index.html;
}
# Cache immutable hashed assets aggressively
location /_app/immutable/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json;
}
}# Kubernetes CronJob: rebuild Evidence nightly and push to S3
apiVersion: batch/v1
kind: CronJob
metadata:
name: evidence-nightly-build
namespace: analytics
spec:
schedule: "30 5 * * *" # 05:30 UTC — after dbt completes at 05:00
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: evidence-builder
image: node:20-alpine
workingDir: /workspace
command: ["/bin/sh", "-c"]
args:
- |
npm ci &&
npm run build &&
npx @aws-sdk/client-s3 sync build/ s3://analytics-reports/ --delete --cache-control "max-age=3600" &&
aws cloudfront create-invalidation --distribution-id ${CF_DISTRIBUTION_ID} --paths "/*"
env:
- name: EVIDENCE_SNOWFLAKE_USERNAME
valueFrom:
secretKeyRef:
name: evidence-credentials
key: snowflake_username
- name: EVIDENCE_SNOWFLAKE_PASSWORD
valueFrom:
secretKeyRef:
name: evidence-credentials
key: snowflake_password
- name: CF_DISTRIBUTION_ID
valueFrom:
secretKeyRef:
name: evidence-credentials
key: cloudfront_distribution_id
volumeMounts:
- name: source
mountPath: /workspace
volumes:
- name: source
gitRepo:
repository: https://github.com/myorg/analytics-reports.git
revision: mainProduction Checklist
Store all data source credentials in environment variables, never in connection.yaml files. Evidence reads connector credentials from env vars at build time. Use GitHub Actions secrets, Kubernetes Secrets, or Evidence Cloud's encrypted credential store. Never commit credentials to the repository.
Add Evidence build to your dbt CI pipeline as a downstream step. A successful dbt run that produces broken Evidence queries is a deployment failure. Run 'npm run build' after dbt test passes and fail the CI job if Evidence exits non-zero — this catches query errors, missing columns, and data type mismatches before they reach production.
Use named queries consistently and document them. Every SQL block on a page becomes a named variable. Short, descriptive names (daily_revenue, cohort_retention, pipeline_status) make pages readable as documents. Avoid generic names like data or rows — they collide across pages and confuse future editors.
Set showQueryEditor: false in production. The query editor is useful during development for iterating on SQL, but exposes your schema and partial data to authenticated users in production. Disable it unless you intentionally want analyst self-service.
Limit dynamic page generation to manageable cardinalities. A [customer_id].md template with 100,000 customers generates 100,000 HTML files at build time, consuming gigabytes of disk and minutes of build time. Add a WHERE clause to the page-generation query to limit to active or high-value entities, or switch to client-side filtering with Inputs components.
Cache Evidence builds in CI with the .evidence/ directory. Evidence caches compiled query results in .evidence/. Mount this directory between CI runs or use GitHub Actions cache with a key based on the dbt manifest hash — unchanged queries skip re-execution, cutting build time from minutes to seconds.
Pin connector package versions in package.json. Connector packages follow semver but breaking changes can slip through minor versions. Pin to exact versions ("@evidence-dev/bigquery": "2.3.1") and test upgrades in a PR before applying to main.
Use Evidence's built-in format strings instead of raw SQL rounding. Evidence format strings (usd0, pct1, num0k) handle locale-sensitive formatting in the browser. Doing rounding and formatting in SQL means the data is wrong for non-US locales and loses precision for future aggregations.
Add a page-level last_updated timestamp from a metadata table. Analytics users need to know how fresh the data is. A small SQL query selecting the max updated_at from a pipeline run log table, displayed in a BigValue component, tells readers exactly when the underlying data was last refreshed.
Validate report accessibility before sharing broadly. Evidence renders semantic HTML with ARIA attributes, but custom Svelte components bypass this. Use axe DevTools or Lighthouse on your built reports to catch color contrast failures and missing labels before distributing to stakeholders who rely on screen readers or high-contrast modes.
Maintaining analytics dashboards in Tableau or Power BI where metric changes require manual updates across dozens of reports, no version control on report definitions, or needing a lightweight self-hosted BI solution that deploys like a static website?
We design and implement Evidence.dev analytics platforms — from project scaffolding and data source connector configuration for DuckDB, BigQuery, Snowflake, and PostgreSQL to SQL report authoring with built-in chart and table components, templated drill-down pages with dynamic route generation, Inputs-driven interactive filtering, dbt pipeline integration with Evidence as the downstream reporting layer, GitHub Actions CI/CD pipelines that run dbt and then Evidence with automated warehouse credential injection, custom Svelte component development for bespoke visualization requirements, static site deployment on Cloudflare Pages or S3 with CloudFront, Kubernetes CronJob nightly rebuild automation, and Nginx or reverse-proxy auth integration for internal access control. Let’s talk.
Let's Talk