ngx_http_sqlite_module is a standalone Nginx dynamic module that turns a location into a configured SQLite-backed HTTP operation. SQL stays in Nginx config, while clients can only provide values for declared bound parameters.
This folder is self-contained so it can be built with:
./configure ... --with-compat --add-dynamic-module=/path/to/nginx_sql_module
make modulescd nginx-sqlite-module
sudo make installThe installer supports hosts with apk, apt-get, dnf, or yum. It builds against the installed Nginx version and installs:
/usr/lib/nginx/modules/ngx_http_sqlite_module.so
/etc/nginx/modules/50-ngx-http-sqlite-module.conf
/usr/local/share/ngx_http_sqlite_module/
Then include module-backed locations in an Nginx server block:
sqlite_db "/var/lib/ngx_http_sqlite_module/app.sqlite";
sqlite_bearer_token "change-me";
sqlite_response json;
sqlite_busy_timeout 3000ms;
sqlite_max_response_size 8m;
location = /api/health-row {
sqlite;
sqlite_operation health;
sqlite_method GET;
sqlite_sql "SELECT 'ok' AS status";
sqlite_read_only on;
sqlite_allow_anon on;
}Declare shared defaults once at server scope, then keep each location
focused on one HTTP operation:
server {
listen 8080;
sqlite_db "/var/lib/app/app.sqlite";
sqlite_bearer_token "change-me";
sqlite_response json;
sqlite_warmup on;
location = /api/ready {
sqlite;
sqlite_operation ready;
sqlite_method GET;
sqlite_sql "SELECT 'ready' AS status";
sqlite_read_only on;
sqlite_allow_anon on;
}
}By default, workers warm up configured SQLite handles during startup and cap
successful JSON responses at 8m. Use sqlite_warmup off; for lazy first-use
setup, sqlite_busy_timeout 0; to fail immediately on lock contention, or
sqlite_max_response_size off; for trusted routes that intentionally return
larger JSON bodies.
location = /api/things/detail {
sqlite;
sqlite_operation get_thing;
sqlite_method GET;
sqlite_sql "SELECT id, name, json(attrs) AS attrs, updated_at AS updatedAt FROM things WHERE id = :id";
sqlite_param id arg:id required raw;
sqlite_read_only on;
sqlite_allow_anon on;
sqlite_json_value attrs;
sqlite_empty_status 404;
}Example request:
curl 'http://localhost:8080/api/things/detail?id=thing-1'location = /api/things {
sqlite;
sqlite_max_body_size 64k;
sqlite_operation upsert_thing;
sqlite_method POST PUT PATCH;
sqlite_sql "INSERT INTO things (id, name, attrs, updated_at) VALUES (:id, :name, json(:attrs), strftime('%s','now')) ON CONFLICT(id) DO UPDATE SET name = excluded.name, attrs = excluded.attrs, updated_at = excluded.updated_at RETURNING id, name, json(attrs) AS attrs, updated_at AS updatedAt";
sqlite_param id json:id required raw;
sqlite_param name json:name required raw;
sqlite_param attrs json:attrs "default:{}" raw;
sqlite_json_value attrs;
sqlite_invalid_json_status 422;
}Example request:
curl -X POST http://localhost:8080/api/things \
-H 'Authorization: Bearer change-me' \
-H 'Content-Type: application/json' \
-d '{"id":"thing-1","name":"Sensor","attrs":{"room":"lab"}}'Use the argon2id transform to store password or API-key hashes, then verify submitted secrets with sqlite_argon2id_verify:
location = /api/login {
sqlite;
sqlite_operation login;
sqlite_method POST;
sqlite_sql "SELECT username FROM users WHERE username = :username AND sqlite_argon2id_verify(password_hash, :password) = 1";
sqlite_param username json:username required raw;
sqlite_param password json:password required raw;
sqlite_empty_status 401;
}Because Argon2id uses a fresh salt, do not look up credentials by rehashing the submitted secret. For API keys, store a public key id or prefix separately and use it to select candidate rows before calling sqlite_argon2id_verify.
Use sqlite_batch when a route needs multiple configured statements, such as a
transaction that writes and then returns the changed row:
location = /api/things/batch {
sqlite;
sqlite_operation upsert_thing_batch;
sqlite_method POST;
sqlite_batch "
BEGIN;
INSERT INTO things (id, name, attrs, updated_at)
VALUES (:id, :name, json(:attrs), strftime('%s','now'))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
attrs = excluded.attrs,
updated_at = excluded.updated_at;
SELECT id, name, json(attrs) AS attrs, updated_at AS updatedAt
FROM things
WHERE id = :id;
COMMIT;
";
sqlite_param id json:id required raw;
sqlite_param name json:name required raw;
sqlite_param attrs json:attrs "default:{}" raw;
sqlite_json_value attrs;
}If the configured SQL does not include explicit transaction statements, add
sqlite_batch_transaction on; to have the module wrap the batch in
BEGIN IMMEDIATE and COMMIT.
location = /api/things {
sqlite;
sqlite_operation delete_thing;
sqlite_method DELETE;
sqlite_sql "DELETE FROM things WHERE id = :id";
sqlite_param id arg:id required raw;
sqlite_require_changes on;
sqlite_response status_only;
sqlite_success_status 204;
sqlite_empty_status 404;
}The subproject includes a Compose setup that builds the dynamic module and the third-party hook sample against the Nginx image, loads examples/example-nginx.conf, applies the bundled example migration, serves the API reference at http://localhost:8080/, serves the OpenAPI document at http://localhost:8080/openapi.json, and serves the example routes on port 8080:
cd nginx-sqlite-module
docker compose up --buildThe Dockerfile defaults to nginx:alpine, bootstraps make, and runs make install KEEP_BUILD_DEPS=true NO_RELOAD=true inside the image so the example uses the same installer path as a host install before compiling the hook sample. Pass --build-arg NGINX_IMAGE=nginx:stable-alpine to test another compatible Nginx Alpine image, or --build-arg NGINX_PACKAGE_SOURCE=alpine to force Alpine's native Nginx package.
Smoke-test the running example server:
scripts/smoke-examples.shBenchmark one example response type:
scripts/bench-examples.sh --type json --requests 200
scripts/bench-examples.sh --type json_rows --requests 200
scripts/bench-examples.sh --type status --requests 200Or run individual requests:
curl http://localhost:8080/examples/bootstrap
curl -X POST http://localhost:8080/examples/devices/json \
-H 'Authorization: Bearer change-me' \
-H 'Content-Type: application/json' \
-d '{"id":"device-1","name":"Sensor","attrs":{"room":"lab"}}'
curl 'http://localhost:8080/examples/devices/detail?id=device-1'
curl 'http://localhost:8080/examples/devices/list?limit=10&offset=0'
curl -X POST http://localhost:8080/examples/projects/flags \
-H 'Authorization: Bearer change-me' \
-H 'Content-Type: application/json' \
-d '{"projectId":"project-1","key":"checkout_v2","enabled":true,"rollout":100,"rules":{"plans":["pro"]}}'
curl 'http://localhost:8080/examples/projects/flags/list?projectId=project-1'
curl 'http://localhost:8080/examples/projects/flags/evaluate?projectId=project-1&key=checkout_v2&subject=user-1'The example SQLite files are stored in the ngx_sqlite_module_data Docker volume. Remove the test data with:
docker compose down -v- INSTALL.md: Linux install and manual build instructions.
- DIRECTIVES.md: full directive reference.
- HOOKS.md: C hook API and semver compatibility checks for third-party Nginx modules.
- examples/example-nginx.conf: JSON, query, auth, status-only, feature flag, third-party hook, and schema examples.
- examples/www/openapi.json: OpenAPI reference for the bundled example endpoints.
- examples/www/index.html: Scalar API reference for the bundled OpenAPI document.
- scripts/migrate-sqlite.sh: optional ordered SQL migration runner.
- scripts/bench-examples.sh: curl-based example endpoint benchmark.
Clients cannot submit SQL. Each route executes SQL configured with sqlite_sql or sqlite_batch, and user input is bound through explicit sqlite_param declarations. Use sqlite_bearer_token for admin routes, sqlite_allow_anon on; for intentional public read-only routes, avoid logging secrets in URLs by using JSON bodies, and use Argon2id for stored API keys, access tokens, and user passwords.