Building a Database Performance Testing Tool With AI: The Honest Breakdown

It still feels a little strange to have AI writing practically all the code — but I decided to give it a real shot on this new project. A bit of context: I was running low on project ideas to share here, so I asked the AI for a list and picked one called Database Performance Testing. The goal was to run performance tests against a relational database. I built it in roughly two days, and in this article I want to share my honest impressions — both technical and about the AI-assisted workflow itself.

Why run performance tests on a database?

I think this is the first question we need to ask before starting any project. As it happens, I’m currently on a project where data performance is a critical system concern — which got me thinking: what would it look like to run performance tests directly against a relational database?

From a QA perspective, performance isn’t just about how the API connects to the database. It’s also about how queries are written and how the chosen database handles query concurrency, especially in synchronous systems. A slow endpoint isn’t always a slow endpoint — sometimes it’s a slow query hiding behind it. That’s what drove me to start here.

What are we testing?

For this project, we have four distinct test scenarios:

  • N+1 Query Detection
  • Deadlock Simulation
  • Query Regression Tracking Across Schema Changes
  • Slow Queries

N+1 Query Detection

N+1 is a classic performance problem in applications that access databases — especially systems using ORMs like Entity Framework, SQLAlchemy, or Sequelize. The name describes exactly what happens: instead of one optimized query, the application ends up running 1 initial query + N additional queries.

Consider an e-commerce system that lists 20 orders and shows the email of each order’s user. A naive approach would be:

SELECT id, user_id FROM orders LIMIT :n;
SELECT email FROM users WHERE id = :uid;

The first query fetches the orders. The second query then runs 20 times — once per order. That’s your N+1. The consequences at scale include multiplied latency, cache pollution, and unpredictable response times for users.

This can also appear in concurrent scenarios. Two threads both fetching user data simultaneously, each triggering their own cascade of lookups, can quickly multiply database load in ways that are hard to trace without instrumentation.

Deadlock Simulation

Imagine two transactions happening concurrently:

Transaction A:

UPDATE orders SET status = 'pending' WHERE id = 1;
UPDATE orders SET status = 'paid'    WHERE id = 2;

Transaction B (running simultaneously, in reverse order):

UPDATE orders SET status = 'paid'    WHERE id = 2;
UPDATE orders SET status = 'pending' WHERE id = 1;

Each transaction holds a lock the other needs. The database detects the cycle and resolves it by rolling back one of the transactions — but that rollback has a cost. At scale, this kind of contention can cause noticeable latency spikes and, in the worst case, lock up parts of your application. The goal of the deadlock test isn’t just to confirm that a deadlock can*happen, but to measure *how the database recovers and what the timing impact looks like.

Query Regression Tracking Across Schema Changes

This scenario focuses on measuring the impact of schema changes — index creation, migrations, table alterations — on query execution plans and timings. It captures EXPLAIN ANALYZE output before and after a migration, then diffs the results.

This is especially useful for:

  • Validating that an optimization actually improved things before merging
  • Documenting schema versions and their performance characteristics over time
  • Supporting production investigations when run periodically as a baseline

Slow Queries

The slow query tests act as a latency gate — a set of critical queries that must stay under a defined threshold (configured in config.py as SLOW_QUERY_THRESHOLD_MS). If any of these queries breach the threshold in CI, the pipeline fails.

Think of it as a performance budget for your most important database operations. In this project, five critical queries were monitored. Here are the results from a low-volume benchmark run:

Query Avg (ms) p95 (ms) Max (ms)
user_lookup 0.20 0.29 0.35
order_history 0.31 0.43 0.75
inventory_search 0.24 0.32 0.62

All three passed the threshold comfortably. But the value here isn’t just the green light — it’s having a historical baseline. The next time someone adds a filter, changes a join, or drops an index, you’ll know immediately if these numbers move.

The solution structure

Here’s how the project is organized:

.
├── config.py                          # DB_URL and SLOW_QUERY_THRESHOLD_MS
├── conftest.py                        # pytest fixtures: engine, instrumented_engine
├── pyproject.toml                     # pytest config and markers
├── requirements.txt
├── analysis/
│   ├── n_plus_one_detector.py         # N+1 detection and simulation
│   ├── deadlock_simulator.py          # Concurrent deadlock demo
│   └── explain_analyzer.py            # EXPLAIN plan capture and diff
├── benchmarks/
│   ├── queries/                       # Raw .sql files (12 queries)
│   ├── scenarios/
│   │   └── run_benchmark.py           # Volume benchmark runner
│   ├── test_n_plus_one.py             # N+1 detection tests
│   ├── test_deadlock.py               # Deadlock tests
│   ├── test_explain.py                # EXPLAIN ANALYZE plan tests
│   └── test_slow_queries.py           # Latency threshold gate (5 critical queries)
├── data/
│   ├── seed.py
│   └── distributions.json
├── migrations/
│   ├── baseline/
│   │   └── 001_initial_schema.sql
│   └── v2_add_indexes/
│       └── 002_add_indexes.sql        # Sample migration for regression demo
├── reports/
│   ├── query_regression_report.py     # Delta reporter (also exports to Grafana)
│   ├── export_metrics.py              # Writes results to benchmark_results table
│   ├── output/                        # Timestamped benchmark JSON results
│   └── plans/                         # Saved EXPLAIN plans
├── scripts/
│   └── setup_schema.py                # Apply migration + snapshot schema
├── .github/
│   └── workflows/
│       └── performance-tests.yml      # CI: schema → seed → pytest
└── docker/
    ├── docker-compose.yml             # PostgreSQL + Grafana
    ├── init.sql
    └── grafana/
        ├── provisioning/
        └── dashboards/benchmark.json  # Auto-provisioned dashboard

A few things worth highlighting in this structure:

analysis/ is designed to be run independently. Each module — N+1 detection, deadlock simulation, EXPLAIN analysis — can be executed on its own. You don’t have to run the full suite every time. This matters in practice: there are moments where you only care about regression tracking after a migration, and running deadlock simulation alongside it is just noise.

explain_analyzer.py captures and diffs EXPLAIN plans. This is what powers the query regression scenario — it saves plan snapshots before and after a schema change and computes the delta. The results are stored under reports/plans/ as timestamped JSON files.

Grafana is provisioned automatically. The benchmark_results table gets populated by export_metrics.py after each run, and the dashboard in grafana/dashboards/benchmark.json is auto-loaded. No manual setup needed.

What the data actually showed

The most interesting results came from the query regression scenario. After adding a composite index (idx_orders_user_created) via migration 002_add_indexes.sql, the EXPLAIN plans for order_history changed dramatically:

Before the index — Sequential Scan:

  • Execution time: 0.532ms
  • The planner scanned the entire orders table (touching 39 blocks) and filtered out 2,497 rows to find 3 matching records
  • Total cost estimate: 101.27

After the index — Bitmap Index Scan:

  • Execution time: 0.090ms
  • The planner used idx_orders_user_created directly, reading only 5 blocks
  • Total cost estimate: 10.92

That’s an 83% reduction in execution time and a ~10x drop in planner cost — from a full table scan touching 2,500 rows down to a targeted index lookup. Without the regression tracking in place, this kind of change would have been invisible unless someone happened to run EXPLAIN manually.

This is exactly the kind of validation that’s worth having in CI: you add an index, run the suite, and get a clear before/after diff that confirms the optimization actually worked.

On building with AI: the honest take

This is where I want to spend some real time, because the AI-assisted workflow was just as interesting to reflect on as the technical output itself.

What I actually did was prompt the AI for a project idea, refine the scope, and then iteratively ask it to generate the solution — structure first, then individual modules, then the CI pipeline. I didn’t write much code from scratch. What I did was spend a significant amount of time reading what it produced, questioning whether the test logic made sense, and validating that the scenarios matched the real-world problems they were meant to simulate.

What felt different about this workflow compared to writing everything myself:

  • I spent more time thinking about whether the tests were correct than about how to implement them. That’s actually a good shift for a QA-focused project — but it felt unfamiliar.
  • The AI was excellent at boilerplate and structure (docker-compose setup, pytest fixtures, file organization) and decent at logic, but it needed guidance on edge cases and test validity.
  • I felt genuinely uncertain about “ownership” of the result. The code works, I understand it, and I made real decisions throughout — but I also couldn’t have shipped it in two days without the AI. That’s a strange feeling to sit with.

Where it genuinely helped: spinning up the Docker + Grafana integration was something I would have spent half a day on manually. The AI had a working docker-compose.yml with auto-provisioned dashboards in minutes. That alone was worth it for a POC.

Where it fell short: the AI tends to produce confident-looking code that needs careful review. A few of the generated SQL files had column names that didn’t match the schema. The N+1 detection logic needed an extra pass to handle the concurrent case correctly. None of these were blockers, but they reinforced that the human-in-the-loop isn’t optional — it’s the job.

I’ll keep experimenting with AI-assisted workflows, particularly for testing tooling and POCs. If you’ve built something similar or have thoughts on the approach, I’d love to hear it in the comments. You can see the full solution here.

Key takeaways

  • Database performance testing is a QA concern, not just a backend one. Slow queries, N+1 patterns, and regression after migrations are all things a QA engineer should be equipped to catch.
  • EXPLAIN plans are underused. Having automated before/after diffs on schema changes is low-effort and high-value. The index migration result above would have been invisible otherwise.
  • Modular test design matters. Being able to run N+1 detection independently from deadlock simulation means you can integrate specific checks into specific workflows without overhead.
  • AI accelerates, but doesn’t replace judgment. The two days I spent on this were productive — but the value came from the decisions I made, not from the code the AI generated.

I’ll keep experimenting with AI-assisted workflows, particularly for testing tooling and POCs. If you’ve built something similar or have thoughts on the approach, I’d love to hear it in the comments.You can check the full solution here.

Hot To Run LLMs Locally

If you are using Claude API, OpenAI API, Cursor, or AI coding tools daily, your API bill can grow very fast.

A lot of developers are now moving to local LLM setups because they want:

  • Lower AI costs
  • Offline AI access
  • Better privacy
  • Faster experimentation
  • No API limits

The good news is:

You can now run powerful AI models directly on your laptop using tools like Ollama (run llm locally).

This setup works great for:

  • Coding help
  • Refactoring
  • Learning
  • Documentation
  • AI chat
  • Small local agents

Let’s set it up step by step.

Step 1: Install Ollama

Download Ollama

Install it normally like any software.

After installation, open CMD or Terminal and check:

ollama --version

If you see a version number, it is installed correctly.

Step 2: Download Your First AI Model

Now pull a model locally.

Example:

ollama pull llama3

Or for coding:

ollama pull qwen2.5-coder:7b

The first download may take a few minutes because models are several GB in size.

Step 3: Run the Model

Start chatting with the model:

ollama run llama3

Example:

>>> Explain Docker in simple words

You now have a local AI assistant running directly on your machine.

No API required.

Step 4: Use It Inside VS Code

Install:

  • Continue.dev
  • Cline

Both work with Ollama locally.

In Continue.dev config:

{
  "models": [
    {
      "title": "Local AI",
      "provider": "ollama",
      "model": "qwen2.5-coder:7b"
    }
  ]
}

Now VS Code can use your local model for:

  • Code generation
  • Refactoring
  • Debugging
  • Chat

Step 5: Open Chat UI in Browser

You can also use a ChatGPT-like interface locally.

Install Open WebUI using Docker:

docker run -d 
-p 3000:8080 
--add-host=host.docker.internal:host-gateway 
-v open-webui:/app/backend/data 
--name open-webui 
ghcr.io/open-webui/open-webui:main

Open:

http://localhost:3000

Now you have your own private AI chat app.

Recommended Models

Model Best For
Qwen2.5 Coder Coding
DeepSeek Coder Refactoring
Llama 3 General AI
Phi Low-end laptops
Mistral Fast responses

Minimum Hardware

Basic setup:

  • 16GB RAM recommended
  • SSD storage
  • NVIDIA GPU helps a lot

CPU-only works too, but slower.

Why Developers Like Local AI

Main reasons:

  • No monthly API bills
  • More privacy
  • Works offline
  • Full control
  • Easy experimentation

For daily coding workflows, local LLMs are becoming surprisingly useful.

Cloud models are still stronger for advanced reasoning, but local AI is now good enough for many real-world tasks.

Final Thoughts

If you are spending too much on AI APIs, this is probably the easiest way to reduce costs.

Start simple:

  • Install Ollama
  • Pull one coding model
  • Connect it to VS Code

That alone can replace a large percentage of your daily AI usage.

Useful links:

Research blockchain with post-quantum Dilithium and custom zk-STARKs from scratch

I built a from-scratch experimental blockchain to explore some ideas I’ve been interested in:
– post-quantum signatures (Dilithium)
– custom zk-STARK system (AIR + FRI, written in Rust)
– shielded + anonymous transactions
– simple P2P gossip network
It’s not production-ready (no persistence, no DoS protection, etc.) — this is purely a learning/research project.
The most interesting part was implementing the STARK system end-to-end and integrating it into a working chain.
I’d really appreciate feedback, especially on: – the AIR design
– the privacy model
– tradeoffs between PQ signatures vs performance
Repo / demo:
https://github.com/sil714/qchain
Happy to answer any questions.

AI agents do not just need tool access. They need execution control.

AI agent infrastructure is moving very quickly.

MCP, tool calling, agent frameworks, and workflow systems are making it much easier for agents to connect to external capabilities.

That is useful, but it creates a second problem.

Once an agent can touch wallets, APIs, databases, files, emails, browser actions, or production systems, the key question is no longer only whether the agent can use a tool.

The question becomes:

Should this agent be allowed to execute this specific action right now?

That means checking things like:

  • authorization
  • evidence
  • policy
  • risk
  • scope
  • amount
  • recipient
  • receipts after execution
  • audit trails

I am building Leviathan Matrix around this problem.

Our framing is:

MCP connects agents to tools.
Leviathan controls when agents are allowed to execute.

We have a product testnet with early Agent Audit Cases:
https://console.leviathanmatrix.com/console

I am looking for feedback from builders working on agents with real permissions. If this is relevant, I can send an API key for testing.

I Renamed a Hot Postgres Table Without Dropping a Request

Most table renames are database-easy and deploy-hard.

The SQL looked trivial:

ALTER TABLE oidc_clients RENAME TO oidc_provider_clients;

In PostgreSQL, that rename is fast and metadata-only.

The real problem was the rollout window around it.

This was a live IAM service on the login path, deployed across multiple pods. During a rolling deploy, there is always a period where some instances run the new code and some still run the old code.

During that window:

  • new pods query oidc_provider_clients
  • old pods still query oidc_clients

If you rename the table and do nothing else, old pods start failing with:

relation "oidc_clients" does not exist

The fix was a two-line compatibility shim:

ALTER TABLE oidc_clients RENAME TO oidc_provider_clients;
CREATE VIEW oidc_clients AS SELECT * FROM oidc_provider_clients;

That was enough to let old pods survive the rollout window while new pods moved to the renamed table.

If the only thing that changed is the table name, the old name can keep working for another minute.

That was the whole trick.

The real failure mode

On a static database, ALTER TABLE ... RENAME is boring.

On a live service, the schema and application versions are temporarily out of sync by design:

  • the migration has already run
  • some pods still serve old code
  • some pods already serve new code

That mismatch window is where destructive naming changes fail.

The usual options are:

  1. accept a maintenance window
  2. do a multi-release expand/contract migration with dual writes
  3. add a short-lived compatibility layer

For a pure rename, option 3 is much cheaper than the other two.

The shim

Right after the rename, create a view with the old name:

ALTER TABLE oidc_clients RENAME TO oidc_provider_clients;
CREATE VIEW oidc_clients AS SELECT * FROM oidc_provider_clients;

Now old code can keep using the old relation name:

SELECT * FROM oidc_clients WHERE "workspaceSlug" = 'acme';

And new code can use the new one:

SELECT * FROM oidc_provider_clients WHERE "workspaceSlug" = 'acme';

When the rollout finishes and no old pods remain, drop the view in a follow-up migration.

For a rename, that is exactly the kind of temporary compatibility boundary you want.

The part people miss: writes

The first reaction to this pattern is usually:

“Fine for reads, but views are read-only.”

That is not true for simple PostgreSQL views.

PostgreSQL automatically makes a view updatable when it is simple enough, which in practice means:

  • one table in the FROM
  • no GROUP BY, DISTINCT, HAVING, LIMIT, OFFSET
  • no set operations
  • no aggregates or window functions

For a shim like this:

CREATE VIEW oidc_clients AS SELECT * FROM oidc_provider_clients;

INSERT, UPDATE, and DELETE route to the base table automatically.

So old code like this still works during the rollout window:

UPDATE oidc_clients
SET revoked = true
WHERE "workspaceSlug" = 'acme';

DELETE FROM oidc_clients
WHERE "workspaceSlug" = '__some_test__';

There is no trigger to write. No dual-write logic. No extra application routing code.

You still get the normal locks and write behavior of hitting the base table. You just do not have to build extra migration machinery to keep the old name alive for 60 seconds.

The objection that mattered

One reviewer raised the real objection:

what about INSERT ... ON CONFLICT DO UPDATE?

That was not hypothetical. One old path still did an upsert against the old relation name.

The concern looked legitimate because a lot of older advice around updatable views says they do not support ON CONFLICT, and search results still surface that warning.

So I tested it directly before complicating the migration.

What I tested

With the renamed table and compatibility view in place:

ALTER TABLE oidc_clients RENAME TO oidc_provider_clients;
CREATE VIEW oidc_clients AS SELECT * FROM oidc_provider_clients;

I ran an upsert through the view:

INSERT INTO oidc_clients ("clientId", metadata)
VALUES ('test_x', '{"v":1}'::jsonb)
ON CONFLICT ("clientId")
DO UPDATE SET metadata = EXCLUDED.metadata;

Then I ran it again with different data to force the conflict path:

INSERT INTO oidc_clients ("clientId", metadata)
VALUES ('test_x', '{"v":2}'::jsonb)
ON CONFLICT ("clientId")
DO UPDATE SET metadata = EXCLUDED.metadata;

Both statements succeeded. The row landed in oidc_provider_clients, and the second statement updated the existing row as expected.

We verified it on PostgreSQL 16.12 in production and 16.13 locally.

The current PostgreSQL docs for CREATE VIEW also say that INSERT statements with ON CONFLICT UPDATE are fully supported on automatically updatable views.

So if your shim is a simple single-table view, modern Postgres can carry more of the rollout than a lot of engineers assume.

Why this beats expand/contract for a pure rename

Expand/contract is still the right answer when the shape changes:

  • new columns with new semantics
  • type changes
  • split or merged fields
  • different write paths

But for a pure rename, dual-write is wasted complexity.

You are not changing where the data logically lives. You are changing the name application code uses to reach it.

That is exactly the kind of mismatch a short-lived compatibility view is good at absorbing.

If PostgreSQL can cheaply preserve compatibility during the rollout, I would rather buy compatibility than orchestrate a bigger migration plan.

When I would use this

This pattern is a good fit when all of these are true:

  • the old and new code need the same row shape
  • the compatibility layer can be a simple single-table view
  • you only need the shim for the duration of a rolling deploy
  • the real change is a rename, not a semantic rewrite

When I would not

Do not use this as a magic answer for every migration.

It stops being the right tool when:

  • the view would need joins or aggregates
  • old and new code disagree on column names or types
  • the application depends on shape changes the view cannot hide
  • you need a long-lived compatibility contract instead of a short-lived rollout shim

One subtle caveat: SELECT * in a view captures the column list at view creation time. Fine for a short-lived rename shim. Another reason not to turn this into a permanent abstraction.

What the rollout looked like

In our case, the release did two things:

  1. archive and delete stale legacy rows
  2. rename the table and create the compatibility view

The first new pod applied the migration. Old pods kept serving traffic against oidc_clients, which was now a view. New pods used oidc_provider_clients. Kubernetes finished rolling the old pods out. Then we removed the shim in a later migration.

No maintenance window.
No dual-write phase.
No dropped login traffic during the rollout.

The takeaway

The rule I would reuse is simple:

If the only thing that changed is the table name, make the old name keep working until the rollout is over.

In PostgreSQL, the cheapest version of that rule is often:

ALTER TABLE old_name RENAME TO new_name;
CREATE VIEW old_name AS SELECT * FROM new_name;

That is a small trick.

It is also the kind of trick that turns a risky production rename into a boring deploy, which is the whole point.

How to Build a Self-Hosted AI Gateway With LiteLLM and Open WebUI

If you’ve ever self-hosted AI tools, you know how quickly things get messy.

One app talks to OpenAI. Another uses Anthropic. You spin up Ollama locally and now there’s a third endpoint to manage. Authentication is different everywhere. Switching models means rewriting integration code. And before long, you’re spending more time maintaining glue code than actually building anything.

I ran into this exact problem — so I built a cleaner setup.

The idea is simple: put a single gateway in front of every provider, so the rest of your stack only ever talks to one API.

I open-sourced the full working implementation here:

🔗 github.com/dixon400/myllm

Clone it. Run docker compose up. You’ll have a working AI gateway in under 30 minutes.

What This Stack Does

  • One API → OpenAI, Anthropic, Groq, and Ollama all behind a single OpenAI-compatible endpoint
  • One frontend → Open WebUI as the unified chat interface
  • Secure remote access → Cloudflare Tunnel, no exposed ports, no open firewall rules
  • Easy to maintain → providers can change underneath without touching your apps

The full setup takes roughly 20–45 minutes depending on Docker image downloads and whether you already have local Ollama models installed.

The Stack

Nothing exotic here — just well-composed tools:

Component Role
LiteLLM Gateway / routing layer
Open WebUI Chat frontend
PostgreSQL State + metadata
Docker Compose Orchestration
Cloudflare Tunnel Secure remote exposure

Architecture

User
  ↓
Open WebUI
  ↓
LiteLLM (gateway)
  ↓
OpenAI / Anthropic / Groq / Ollama

The key insight: your apps and frontend only talk to LiteLLM. Providers become interchangeable underneath. Add a new model, swap a provider, change routing — nothing else needs to know.

Who This Is For

  • Developers experimenting with local AI infrastructure
  • Teams consolidating multiple providers behind one API layer
  • Engineers building internal AI tooling
  • Anyone tired of maintaining separate provider integrations

If you’ve ever thought “there has to be a simpler way to manage all these AI endpoints” — this is that simpler way.

Why This Stack Exists

Most self-hosted AI environments become hard to manage surprisingly fast. One application talks directly to OpenAI. Another uses Anthropic separately. Local Ollama models need their own endpoints. Authentication is inconsistent, and model switching slowly turns into infrastructure sprawl.

By placing LiteLLM in front of every provider, the rest of your system only needs to understand one interface. Providers can change, local models can be added, routing logic can evolve — without rewriting frontend or application logic every time.

Prerequisites

Before starting containers, make sure you have:

  • Docker Desktop
  • Docker Compose
  • curl
  • cloudflared
  • Ollama (optional, for local models)

Quick verification:

docker --version
docker compose version
cloudflared --version
curl --version

If using local Ollama models:

ollama list

If installed models appear, local inference is ready.

Repository Structure

The repo is intentionally lightweight:

🔗 github.com/dixon400/myllm

├── Docker-compose.yml
├── litellm-config.yml
└── .env

Each file has a distinct job: Docker Compose orchestrates services, LiteLLM config handles routing and model aliases, .env stores secrets and runtime configuration.

Setting Up Environment Variables

Your .env file is where provider credentials live. Create or update it in the project root:

LITELLM_MASTER_KEY=sk-very-strong-key
OPENAI_API_KEY=...
ANTHROPIC_API_KEY=...
GROQ_API_KEY=...
OLLAMA_CLOUD_API_BASE=https://<host>/v1
OLLAMA_CLOUD_API_KEY=...

A few things that matter more than people expect:

  • LITELLM_MASTER_KEY becomes the authentication layer between Open WebUI and LiteLLM
  • These values should never be committed into Git
  • Weak keys become a real security problem once remote access exists

Even if this starts as a personal setup, treat the environment config like production from day one.

Wiring Docker Compose

The goal here is making sure the containers can talk to each other. Most deployment failures come from small config mismatches rather than Docker itself.

Your Docker-compose.yml should point Open WebUI directly at LiteLLM:

OPENAI_API_BASE_URL=http://litellm:4000/v1
OPENAI_API_KEY=${LITELLM_MASTER_KEY}

This tells Open WebUI where the gateway lives and which key to use.

LiteLLM should mount the configuration file correctly:

./litellm-config.yml:/app/config.yaml

⚠️ That filename matters more than it looks. A typo here can cause Docker to create a directory instead of mounting the file — which leads to confusing startup errors later.

Configuring LiteLLM

Inside litellm-config.yml, LiteLLM defines model aliases, provider routing, gateway behavior, and authentication settings.

The most important section is the master key:

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY

This keeps secrets outside the YAML file and makes credential rotation easier later.

Inside model_list, make sure provider model IDs are current. Model names change more frequently than most people expect — especially across Groq and newer OpenAI releases.

Starting the Stack

Once your config looks right, start everything:

docker compose up -d --force-recreate

The initial startup may take a minute while Docker pulls images, initializes PostgreSQL, and creates container state.

Verify container health:

docker ps --format 'table {{.Names}}t{{.Status}}t{{.Ports}}'

You should see open-webui, litellm, and litellm-db all running.

If a container exits immediately, check its logs before moving forward:

docker logs <container-name>

Validating the Gateway

Before touching Open WebUI, validate LiteLLM first. The /v1/models endpoint confirms authentication works, providers loaded correctly, and model routing initialized.

set -a && source .env && 
curl -s http://localhost:4000/v1/models 
  -H "Authorization: Bearer $LITELLM_MASTER_KEY"

For readable output:

set -a && source .env && 
curl -s http://localhost:4000/v1/models 
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" 
  | python3 -m json.tool | head -n 80

If this endpoint fails, Open WebUI will almost certainly fail too — so resolve gateway issues first.

Verifying Open WebUI

Once LiteLLM responds correctly, open the interface:

http://localhost:3000

You should be able to create chats, select models, and send prompts normally.

If the model dropdown is empty, LiteLLM authentication is usually the cause — mismatched master keys, stale model IDs, or invalid provider credentials.

Keeping Provider Models Current

This catches a lot of people off guard: provider model identifiers change more often than you’d think. A deployment that worked perfectly a few months ago can break because a provider deprecated a model name.

Checking Local Ollama Models

ollama list

Checking Groq Models

set -a && source .env && 
curl -s https://api.groq.com/openai/v1/models 
  -H "Authorization: Bearer $GROQ_API_KEY" 
  -H "Content-Type: application/json"

After updating model IDs in litellm-config.yml, recreate the stack:

docker compose up -d --force-recreate

Secure Remote Access with Cloudflare Tunnel

At this point, the stack only exists locally. The next step is exposing Open WebUI to the internet — without opening inbound ports, exposing your home IP, or managing reverse proxies manually.

Cloudflare Tunnel creates an outbound encrypted connection from your machine to Cloudflare’s edge. You get:

  • Automatic HTTPS
  • Hidden origin infrastructure
  • Cloudflare proxy protection
  • Simple DNS management

Move DNS to Cloudflare

  1. Add your domain to Cloudflare
  2. Update nameservers at your registrar
  3. Wait for propagation

Authenticate cloudflared

cloudflared tunnel login

This opens a browser window for authorization.

Create the Tunnel

cloudflared tunnel create openwebui

Cloudflare generates a tunnel UUID and a credentials JSON file.

Route a Subdomain

Assuming your domain is chat.yourdomain.tech:

cloudflared tunnel route dns openwebui chat.yourdomain.tech

Create the Tunnel Configuration

Create ~/.cloudflared/config.yml:

tunnel: openwebui
credentials-file: ~/.cloudflared/<tunnel-uuid>.json

ingress:
  - hostname: chat.yourdomain.tech
    service: http://localhost:3000
  - service: http_status:404

Replace <tunnel-uuid> with the generated filename.

Run the Tunnel

cloudflared tunnel run openwebui

For persistent startup on macOS:

cloudflared service install
cloudflared service start

Running the tunnel as a background service is significantly more reliable than keeping it in a terminal session.

Verifying Remote Access

Quick DNS and connectivity check:

dig +short chat.yourdomain.tech
curl -I https://chat.yourdomain.tech

Always use HTTPS for remote access. Cloudflare Tunnel is designed around secure proxied traffic.

Operational Health Checks

Once the stack is stable, this single command gives you a quick overview of everything:

set -a && source .env && 
echo "== Docker services ==" && 
docker ps --format 'table {{.Names}}t{{.Status}}t{{.Ports}}' && 
echo "n== Local Ollama models ==" && 
ollama list && 
echo "n== Groq model count ==" && 
curl -s https://api.groq.com/openai/v1/models 
  -H "Authorization: Bearer $GROQ_API_KEY" 
  -H "Content-Type: application/json" 
  | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get("data", [])))' && 
echo "n== LiteLLM models ==" && 
curl -s http://localhost:4000/v1/models 
  -H "Authorization: Bearer $LITELLM_MASTER_KEY"

Even for personal deployments, having this kind of visibility saves a lot of debugging time.

Troubleshooting

Stable deployments drift. Provider APIs change, Docker mounts break, credentials expire, model IDs get deprecated. Here are the most common failure patterns.

Open WebUI Loads but Models Are Missing

Empty dropdowns, missing providers, or authentication errors in LiteLLM logs.

docker logs --tail 200 litellm

Verify model visibility directly:

set -a && source .env && 
curl -s http://localhost:4000/v1/models 
  -H "Authorization: Bearer $LITELLM_MASTER_KEY"

Typical fixes:

  • Verify OPENAI_API_KEY=${LITELLM_MASTER_KEY}
  • Confirm master_key uses the environment variable
  • Recreate containers:
docker compose up -d --force-recreate
docker compose restart open-webui

LiteLLM Fails with IsADirectoryError

Docker accidentally created a directory instead of mounting the YAML file.

ls -la ./litellm-config.yml ./litellm-config.yaml
grep -n "litellm-config" Docker-compose.yml

Correct mount:

./litellm-config.yml:/app/config.yaml

Then recreate:

docker compose up -d --force-recreate litellm

Works Locally but Not Through Cloudflare

If local access works but the public hostname fails, focus on the tunnel:

cloudflared tunnel list
cloudflared tunnel info openwebui
cat ~/.cloudflared/config.yml
dig +short chat.yourdomain.tech
curl -I https://chat.yourdomain.tech

Most remote-access failures come from inactive tunnel connectors, incorrect ingress targets, missing proxied DNS records, or running cloudflared in a temporary terminal session.

Models Appear but Generation Fails

If /v1/models works but prompts fail — provider credentials may be invalid, quotas exhausted, or model IDs no longer exist.

set -a && source .env && 
env | grep -E '^(OPENAI_API_KEY|GROQ_API_KEY|ANTHROPIC_API_KEY|LITELLM_MASTER_KEY)=' 
  | sed 's/=.*/=<set>/'

Then inspect LiteLLM logs:

docker logs --tail 300 litellm

Refreshing provider model IDs solves this surprisingly often.

Security Recommendations

Once remote access exists, basic hardening matters:

  • Use a strong LITELLM_MASTER_KEY
  • Don’t expose LiteLLM directly to the internet
  • Rotate provider API keys periodically
  • Keep CORS rules restrictive

For private or team usage, Cloudflare Access adds identity-aware access control in front of Open WebUI — worth enabling.

Capture a Known-Good Baseline

Once things are stable, save:

  • docker ps output
  • /v1/models output
  • Active model aliases
  • Tunnel status:
cloudflared tunnel info openwebui

When something breaks later, comparing against a working snapshot is almost always faster than debugging from scratch.

Try It Yourself

The full working implementation — Docker Compose, LiteLLM config, environment setup — is all here:

🔗 github.com/dixon400/myllm

Clone it, add your API keys, run docker compose up, and you’ll have a working AI gateway.

If you found this useful, ⭐ the repo — it helps more people find it.

Got questions or improvements? Open an issue or drop a comment below. I’m actively maintaining this.

Originally published on HackerNoon.

What is a Webhook? A Complete Guide for Beginners

If you have ever wondered what a webhook is and how it works, this guide is for you. We will explain webhooks in simple terms, show real-world examples, and help you understand why they are essential for modern applications.

The Simple Explanation

Think of a webhook like a pizza delivery notification.

Without webhooks (polling): You keep calling the pizza place asking “Is my pizza ready?” every 5 minutes. Wastes your time and theirs.

With webhooks: The pizza place calls YOU when your pizza is ready. You get notified instantly, no wasted calls.

That is exactly how webhooks work in software. Instead of your application constantly asking a server “Is there new data?”, the server sends your application a notification when something happens.

How Webhooks Work

Here is the step-by-step flow:

Step 1: You Register a URL

You tell a service: “When something happens, send data to this URL.” This URL is your webhook endpoint — a piece of code on your server that listens for incoming requests.

Step 2: Something Happens

An event occurs in the service — a payment succeeds, a user signs up, an order ships, a code commit is pushed.

Step 3: The Service Sends a POST Request

The service sends an HTTP POST request to your URL with the event data (called a “payload”). This happens automatically, in real-time.

Step 4: Your Server Processes It

Your server receives the data and takes action — updates a database, sends an email, triggers a workflow, or notifies a user.

Real-World Examples

Here are common webhook use cases you probably encounter every day:

  • Payment notifications — Stripe sends a webhook when a payment succeeds or fails
  • CI/CD pipelines — GitHub sends a webhook when code is pushed, triggering a build
  • Chat bots — Slack or Discord sends a webhook when a message is posted
  • E-commerce — Order created, shipped, delivered — each triggers a webhook
  • AI agents — An AI agent sends a webhook when a task completes
  • Monitoring — An alert system sends a webhook when a server goes down

Webhook vs API vs Polling

Understanding the difference between these approaches is crucial:

Aspect Polling Webhook
Direction You check periodically Server notifies you
Timing Seconds to minutes Milliseconds (real-time)
Efficiency Wastes bandwidth Only sends when needed
Complexity Simple to implement Needs endpoint setup

When to Use Polling

  • The API does not support webhooks
  • You need very infrequent updates (daily or weekly)
  • You are building a quick prototype

When to Use Webhooks

  • You need real-time updates
  • High volume of events
  • You want to reduce API calls
  • Building production integrations

Webhook Security

Webhooks are sent over HTTP, so anyone can send a request to your URL. You need to verify that the request actually came from the expected service.

HMAC Signatures

The most common verification method. The service signs the payload with a secret key. You verify the signature on your end using the same secret.

HTTPS Only

Always use HTTPS for your webhook endpoints. Never accept webhooks over plain HTTP in production.

Timestamp Validation

Check the timestamp in the webhook header. Reject requests older than 5 minutes to prevent replay attacks.

Getting Started with Webhooks

The easiest way to start with webhooks:

  1. Create an endpoint on your server (a URL that accepts POST requests)
  2. Register that URL with the service that will send webhooks
  3. Verify the webhook signature in your endpoint
  4. Process the event data and take action
  5. Return a 200 status code to acknowledge receipt

Pro Tip

Use a webhook service like HookSniff to handle retries, security, and monitoring — so you can focus on your product. HookSniff delivers webhooks reliably with automatic retries, HMAC-SHA256 signatures, and a real-time dashboard.

Common Webhook Challenges

Failed Deliveries

What happens when your server is down? Without a retry system, the webhook is lost. Services like HookSniff automatically retry failed deliveries with exponential backoff.

Duplicate Deliveries

Webhooks can be delivered more than once. Always use idempotency keys to prevent duplicate processing.

Ordering

Events might arrive out of order. Use sequence numbers or timestamps to handle this.

Debugging

When something goes wrong, you need visibility. A delivery dashboard shows every attempt, status code, and response body.

Conclusion

Webhooks are the foundation of modern event-driven architecture. They enable real-time integrations between services without the overhead of constant polling.

Whether you are building a payment integration, a CI/CD pipeline, or an AI agent system, understanding webhooks is essential.

Ready to start using webhooks? Get started with HookSniff for free — 10,000 webhooks per month, no credit card required.

Originally published at hooksniff.vercel.app/blog/what-is-a-webhook

Headless BI: How a Universal Semantic Layer Replaces Tool-Specific Models

Headless BI — one semantic layer serving all consumers

Headless BI — one semantic layer serving all consumers

Your organization uses Tableau for executive dashboards, Power BI for operational reports, and Python notebooks for data science. Revenue is defined in Tableau’s calculated field, Power BI’s DAX measure, and a SQL query inside a Jupyter notebook. Three tools. Three definitions. None of them match.

This is what happens when semantic models are locked inside BI tools. Headless BI fixes it by pulling the definitions out.

The Problem with Tool-Specific Semantic Models

BI tool lock-in — metrics trapped in isolated silos

Every major BI tool comes with its own modeling layer. Looker has LookML. Tableau has the Data Model. Power BI has DAX and the tabular model. Each one defines metrics, relationships, and calculated fields in a proprietary format.

This creates three problems:

Definition duplication. Every metric must be defined in every tool. Revenue in Tableau. Revenue in Power BI. Revenue in the data science notebook. When the formula changes (say, a new exclusion rule is added), you update it in three places. Or you forget one, and your dashboards disagree.

Tool lock-in. Your metric definitions are trapped inside the tool’s proprietary format. Switching from Tableau to a different visualization layer means rebuilding every metric from scratch. The data model doesn’t migrate.

AI agent exclusion. When you add an AI agent to your stack, it can’t access the Looker LookML definitions or the Power BI DAX measures. It has no semantic model to work with. It generates SQL based on raw table schemas and gets the formulas wrong.

What Headless BI Means

Headless BI is an architecture pattern where metric definitions and business logic are decoupled from the visualization layer. The “head” (the dashboard or chart) is separate from the “body” (the semantic definitions).

In a headless architecture:

  • Metrics are defined once in a platform-neutral semantic layer
  • Definitions are exposed via standard interfaces: SQL, JDBC, ODBC, Arrow Flight, REST
  • Any tool — Tableau, Power BI, Python, an AI agent, a custom app — connects to the same definitions
  • Adding a new visualization tool requires zero metric migration

The semantic layer becomes a shared service. Visualization tools consume it. They don’t own it.

Tool-Specific vs. Universal Semantic Layer

Tool-Specific vs. Universal Semantic Layer

What Composable Analytics Looks Like

Composable analytics — modular blocks snapping together

Headless BI is one piece of a broader shift called composable analytics. Instead of buying a monolithic BI platform that bundles data modeling, metric definitions, and visualizations together, you assemble your analytics stack from modular, interchangeable components.

The semantic layer is the metric module. Choose any visualization tool on top. Choose any data storage underneath. Swap components without rebuilding definitions.

This modularity matters most for AI. An AI agent becomes a first-class consumer of the semantic layer, alongside dashboards and notebooks. It connects to the same interface, reads the same metric definitions, and gets the same answers. No special integration needed.

How This Works in Practice

Dremio functions as a universal semantic layer that any tool can consume. The architecture:

  1. Virtual datasets (SQL views) define business logic and metric calculations once
  2. Wikis and Labels document business context for human and AI consumers
  3. Fine-Grained Access Control enforces security policies at the query level
  4. Reflections optimize performance automatically for any consumer

Connection options include ODBC, JDBC, Arrow Flight (for columnar high-speed clients), and REST API. A Tableau dashboard connects via ODBC. A Python notebook connects via Arrow Flight. Dremio’s AI Agent reads the Wikis and Labels to generate accurate SQL from natural language. All three hit the same virtual datasets. All three get the same answers.

Because the entire semantic layer is built on open standards (Apache Iceberg for data, Apache Polaris for the catalog), the definitions aren’t locked to Dremio’s format. You can inspect, export, and query the same data with any Iceberg-compatible engine.

What to Do Next

Count the number of places your organization defines its top metric (probably Revenue or Monthly Active Users). If that number is greater than one, you’re paying a consistency tax every time someone changes the formula. A universal semantic layer eliminates that tax by defining it once and serving it everywhere.

Try Dremio Cloud free for 30 days

Beyond Translation: A Developer’s Guide to App Localization (i18n & l10n)

As developers, we spend countless hours optimizing our code, refining UI/UX, and ensuring our apps run flawlessly. But what happens when your app needs to scale globally?

Many developers mistake localization (l10n) for simple translation. They think wrapping strings in a t() function and dumping them into a JSON file is enough. In reality, proper application localization is a deeply technical challenge that involves architecture, formatting, and cultural adaptation.

Let’s dive into what it actually takes to localise an application properly and how you can prepare your codebase for global success.

1. Internationalization (i18n) vs. Localization (l10n)

Before writing any code, it’s crucial to understand the distinction:

  • Internationalization (i18n): This is the engineering phase. It’s how you design and build your application so that it can be adapted to various languages and regions without engineering changes.
  • Localization (l10n): This is the implementation phase. It’s the actual process of adapting the internationalized app for a specific region by translating text, changing date formats, adapting currencies, and adjusting layouts.

2. The Technical Hurdles of Localization

A. Dynamic UI and Right-to-Left (RTL) Layouts

If you are localizing languages like Arabic or Hebrew, you can’t just flip the text alignment. The entire layout needs to be mirrored.

Modern CSS makes this much easier with CSS Logical Properties. Instead of using margin-left or padding-right, you should start using:

/* Old way */

.card {

  margin-left: 20px;

  text-align: left;

}

/* Modern, Localization-friendly way */

.card {

  margin-inline-start: 20px;

  text-align: start;

}

Using start and end instead of left and right ensures your layout adapts automatically when the HTML dir=”rtl” attribute changes.

B. Pluralization and Gender Rules

Pluralization is a nightmare if hardcoded. While English only has two plural forms (one / other), languages like Arabic have six different grammatical numbers (zero, one, two, few, many, other).

Never do this:

// ❌ BAD PRACTICE

const message = `${count} ${count === 1 ? ‘item’ : ‘items’} found`;

Instead, utilize native browser APIs like Intl.PluralRules or robust frameworks (like i18next or FormatJS) that handle complex pluralization schemas natively.

C. Date, Time, and Number Formatting

Different countries format numbers and dates uniquely. Hardcoding these formats will break the user experience.

// ✅ GOOD PRACTICE using native Intl API

const number = 123456.789;

console.log(new Intl.NumberFormat(‘de-DE’).format(number)); // “123.456,789”

console.log(new Intl.NumberFormat(‘ar-EG’).format(number)); // “١٢٣٤٥٦٫٧٨٩”

3. Integrating Localization into Your CI/CD Pipeline

For production-grade applications, managing raw translation files (.json, .po, .yaml) manually within Git is inefficient and prone to syntax errors.

The ideal workflow involves:

  1. Extraction: Automating the extraction of keys from your source code.
  2. Synchronization: Syncing these keys with a Translation Management System (TMS) via API or CLI.
  3. Professional Translation: Letting professional localization engineers handle the linguistic and cultural nuances.

When it comes to complex mobile and web ecosystems, partnering with established experts can save weeks of development time. Utilizing dedicated app localization services ensures that your technical infrastructure perfectly aligns with native linguistic accuracy, preventing UI breakage and broken workflows before your app hits the App Store or Google Play.

Summary Checklist for Developers

  • Use CSS Logical Properties (margin-inline-start, etc.) to support RTL effortlessly.
  • Avoid string concatenation; use placeholders for variables.
  • Never hardcode date, time, or currency formats—rely on the Intl API or robust libraries.
  • Establish a seamless pipeline to update locale assets without redeploying the core app.

Localizing an app is a true test of clean code architecture. By implementing these practices early on, you ensure your codebase remains maintainable, scalable, and ready for a global audience.

How do you handle localization in your current stack? Do you prefer native APIs or external frameworks? Let’s discuss in the comments below!