Dynoxide
A DynamoDB emulator backed by SQLite. Runs as an HTTP server, an MCP server for coding agents, or embeds directly into Rust and iOS applications as a library.
Why Dynoxide?
I built Dynoxide because DynamoDB Local is slow, heavy, and can't embed. It needs a JVM, and the typical Docker-based setups adds 2–3 seconds of cold-start, ~188 MB of memory at idle, and a ~225MB Docker image (~471 MB on disk) before you've done anything useful. If you're running integration tests, that's Docker starting, the JVM warming up, and your pipeline waiting.
Dynoxide is a native binary. It starts in milliseconds, idles at ~4.9 MB, and ships as a ~3 MB download. Point any DynamoDB SDK at it and your tests just work.
For Rust projects, there's also an embedded mode - direct API calls via Database::memory() with no HTTP layer at all. Each test gets an isolated in-memory database with zero startup cost. And because it compiles to a native library with no runtime dependencies, it runs on platforms where DynamoDB Local can't, including iOS.
Performance
Local Development (Apple Silicon)
| Metric | Dynoxide (embedded) | Dynoxide (HTTP) | DynamoDB Local |
|---|---|---|---|
| Cold startup | ~0.2ms | ~15ms | ~2,287ms |
| GetItem (p50) | 9µs | 0.1ms | 0.8ms |
| PutItem throughput | ~51,613 ops/s | ~6,703 ops/s | ~945 ops/s |
| 50-test suite (sequential) | ~484ms | ~569ms | ~2,407ms |
| 50-test suite (4x parallel) | ~203ms | ~235ms | ~1,189ms |
CI (GitHub Actions)
Numbers from ubuntu-latest (2-core AMD EPYC 7763, 8GB RAM). Commit f5052db.
| Metric | Dynoxide (embedded) | Dynoxide (HTTP) | DynamoDB Local | LocalStack (all services) |
|---|---|---|---|---|
| Cold startup | <1ms | ~2ms | ~2,769ms | ~8,627ms |
| GetItem (p50) | 14µs | 0.3ms | 0.8ms | — |
| 50-test CI suite | 722ms | 731ms | 2,265ms | — |
| Full workload (10K items) | — | 2.9s | 10.8s | — |
| Binary / image (download) | ~3 MB | ~3 MB | 225 MB | 1.1 GB |
| Binary / image (on disk) | 6 MB | 6 MB | 471 MB | 1.1 GB |
| Idle memory (RSS) | ~4.9 MB | ~8 MB | ~188 MB | ~358 MB |
The gap is wider on Apple Silicon because the faster CPU amplifies the difference between native code and JVM overhead. Both are real measurements of the same benchmark suite. Full methodology and per-operation breakdowns →
Conformance
Dynoxide is continuously verified against real DynamoDB by the dynamodb-conformance suite, which runs one test matrix against AWS itself and every major DynamoDB emulator. Pass rates move as the suite grows and each engine changes, so rather than pin a snapshot that goes stale, see the live standings:
- dynamodb-conformance.org: current pass rates for every engine, broken down by tier
- nubo-db/dynamodb-conformance: the suite itself, the raw results, and how each target is run
This covers the native build. The WebAssembly build is a preview and isn't run against the suite yet.
How It Compares
| Dynoxide | DynamoDB Local | LocalStack (all services) | dynalite | |
|---|---|---|---|---|
| Language | Rust | Java | Python + Java | Node.js |
| Storage | SQLite | SQLite | SQLite (via DDB Local) | LevelDB |
| Runtime dependency | — | JVM | Docker + LocalStack | Node.js |
| Embeddable (Rust / iOS) | ✓ | — | — | — |
| MCP server for agents | ✓ | — | — | — |
LocalStack uses DynamoDB Local internally as its DynamoDB engine, so its startup and memory overhead includes DynamoDB Local's JVM plus LocalStack's own Python routing layer.
Installation
npm
Or run directly without installing:
Homebrew (macOS)
Pre-built binaries
Download from GitHub Releases for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows.
# Example: Linux x86_64
|
Cargo
# With encryption support (SQLCipher + vendored OpenSSL)
As a library (Rust)
[]
# Minimal - just the embedded database, no server or CLI dependencies
= { = "0.10", = false, = ["native-sqlite"] }
# Or with encryption:
# dynoxide-rs = { version = "0.10", default-features = false, features = ["encryption"] }
Upgrading from 0.9.x
0.10.0 is a breaking release, but most of the breaks are library-only. The CHANGELOG has the full list.
Running the binary (Homebrew, npm, the release archives, or the Docker image)? One change affects you:
- MCP over HTTP now requires a bearer token. Existing HTTP-transport clients break until they send an
Authorization: Bearer <token>header. A loopback bind generates and persists a token on first run; a non-loopback bind will not start without one (--mcp-tokenorDYNOXIDE_MCP_AUTH_TOKEN). The stdio transport is unaffected, and plaindynoxide serve(DynamoDB only, no MCP) is unchanged. See MCP Server.
Depending on the dynoxide-rs crate? Also note:
DynoxideErroris now#[non_exhaustive]. Code that matches it exhaustively needs a_ =>arm.Databaseis now generic,Database<S>. The parameter defaults to the native backend, so code that namesDatabasekeeps compiling; a newNativeDatabasealias names that default explicitly.- Embedding the MCP HTTP server:
dynoxide::mcp::serve_httpandserve_http_with_shutdowntake anHttpOptionsstruct (bind host, auth mode, allowed hosts) in place of a bare port.
GitHub Actions
- uses: nubo-db/dynoxide/action@v0.10.0
with:
snapshot-url: https://example.com/test-data.db.zst # optional
port: 8000
See action/action.yml for all inputs and outputs.
Docker
A 5 MB drop-in for amazon/dynamodb-local in containerised test suites. Same DynamoDB-compatible API, faster startup, smaller image. Note that this is a packaging convenience for test fixtures, not a containerised database product; production-database-on-Kubernetes patterns are out of scope.
With persistent storage:
The image runs as root by default, matching amazon/dynamodb-local, so bind mounts on Linux Just Work without --user. The canonical image lives at ghcr.io/nubo-db/dynoxide. Mirrors are pushed to docker.io/nubodb/dynoxide and public.ecr.aws/h4s0n6a2/dynoxide on a best-effort basis. SLSA provenance and SBOM attestations are published to GHCR only; if you want to verify provenance, pull from the GHCR canonical.
If you override CMD to bind to a different port, set the healthcheck target with environment variables so the container's HEALTHCHECK follows:
DYNOXIDE_HEALTHCHECK_HOST and DYNOXIDE_HEALTHCHECK_PORT are documented public surface and will not be renamed in a patch or minor release.
Running as nonroot
For security-conscious operators, opt into a nonroot uid:
Persistent mode under nonroot needs a host-owned bind mount, since the in-image /data is owned by root:
The default in-memory mode needs no flags whether root or nonroot. The uid 65532 is the well-known nonroot uid used by Google's distroless images; pick any uid you prefer with --user <uid>:<gid>.
MCP over HTTP in Docker
The default image serves DynamoDB only. To also expose the MCP Streamable HTTP transport, override the command to start it on 0.0.0.0 and supply a bearer token. The token is mandatory for any non-loopback bind. Pass it via the DYNOXIDE_MCP_AUTH_TOKEN environment variable (which keeps it out of shell history and ps), not a --mcp-token flag:
TOKEN=
DynamoDB is then reachable on http://localhost:8000 and MCP on http://localhost:19280/mcp. Point an HTTP-transport MCP client at the latter with an Authorization: Bearer <token> header. See MCP Server for the client config shape.
A few things to know:
- The token is not optional. Omit it and the container exits immediately with
a non-loopback MCP bind requires an explicit token. The defaultdocker run ghcr.io/nubo-db/dynoxidestays DynamoDB-only precisely because a token-less0.0.0.0MCP bind cannot boot. - Reaching MCP from another container by service name (rather than
localhost) needs that name added to the Host allowlist:--mcp-allowed-host <name>(e.g.--mcp-allowed-host dynoxide). The-p-mappedlocalhostaccess above needs nothing extra. --network host(Linux only) is an alternative to-p, but it bypasses Docker network isolation and binds MCP directly on the host's network interface, reachable from the LAN, not just the host. Prefer-punless you specifically need host networking.
WebAssembly (preview)
Dynoxide compiles to wasm32-unknown-unknown and runs in the browser. The same engine that backs the native build runs against wa-sqlite - a WASM build of SQLite - over a wasm-bindgen bridge, with the database persisted to OPFS (the origin private file system).
Both backends issue the same SQL. The native and wasm code share one set of query builders, so a query fixed on one is fixed on both.
It's a preview. The wasm build is not run against the conformance suite that backs the native build, so its correctness rests on its own tests for now. A build made with --features wasm-sqlite exposes dynoxide::WASM_PREVIEW (true) so you can tell which path you're on.
What works: create table, put, get, delete, query, and scan, over base tables and both secondary index types (GSI and LSI). Index maintenance is atomic with the base write, same as native.
What doesn't, yet: TTL returns a typed Unsupported error (it needs a background sweep the browser doesn't drive). Streams are planned but not wired - the delivery mechanism is still to be decided. TransactWriteItems, tags, table-setting updates, table stats, and bulk import return a preview "not yet implemented" error.
The engine runs in a Web Worker (OPFS's synchronous file handles are Worker-only), and the page talks to it over a message channel. It needs no special server headers (no COOP/COEP cross-origin isolation), so it works on ordinary static hosting.
Building and shipping it
npm install then npm run build:wasm produces a self-contained dist/ (use build:wasm:dev to skip wasm-opt for speed):
dist/ is three files, kept separate so the two .wasm cache independently of the JS bundle:
| File | Size | What |
|---|---|---|
dynoxide_bg.wasm |
~550 KB | the engine (release, wasm-opt) |
wa-sqlite.wasm |
~545 KB | SQLite (the synchronous build) |
dynoxide-worker.js |
~120 KB | the bundled Web Worker (wa-sqlite glue + bridge) |
About 1.2 MB total. Not tiny, but the .wasm files are immutable and cache well, and using wa-sqlite's synchronous build keeps it off the ~1.1 MB Asyncify async build.
Drop dist/ on any origin that's a secure context - HTTPS in production, or localhost for development. OPFS needs a secure context, but no COOP/COEP headers and no cross-origin isolation, so plain static hosting works. (SQLite in the browser usually needs cross-origin isolation, because the common technique makes an async storage API look synchronous via SharedArrayBuffer. Dynoxide avoids that by running wa-sqlite's synchronous OPFS VFS inside a Worker, where synchronous file handles are available directly.)
Spawn the bundle as a module Worker and drive it over postMessage; the two .wasm files must sit next to dynoxide-worker.js, which is where the build puts them. The harness under harness/ is a working example, and it loads the same bundled Worker a production consumer would:
# then open http://localhost:8081/harness/
It runs a CRUD round-trip, a GSI write/query/scan, and an error-envelope check against the OPFS-backed database. Because it drives the shipping bundle rather than a parallel build, a green harness means the shipping artefact works.
The bridge and Worker use bare module specifiers, so the same source also feeds a bundler-target build (vite, webpack, esbuild) for consumers who would rather an npm package. That path is a follow-up.
HTTP Server
Start the server:
With a persistent database:
With encryption (requires the encrypted-server build):
# Generate a key
# Start with key file
# Or via environment variable
DYNOXIDE_ENCRYPTION_KEY=
Then use the AWS CLI or any DynamoDB SDK pointed at localhost:
Works with any language or SDK that supports custom endpoints: Python (boto3), Node.js (AWS SDK v3), Go, Java, etc.
MCP Server
Dynoxide includes an MCP server that exposes DynamoDB operations as tools for coding agents (Claude Code, Cursor, etc.).
stdio transport (default)
Streamable HTTP transport
The HTTP transport requires a bearer token on every request. On a loopback
bind with no token supplied, dynoxide generates one on first run, saves it to a
per-user config file (~/.config/dynoxide/mcp-token on Linux,
~/Library/Application Support/dynoxide/mcp-token on macOS), and prints a
ready-to-paste client snippet; later runs reuse it silently. Supply your own
with --token or the DYNOXIDE_MCP_AUTH_TOKEN environment variable (the flag
wins if both are set).
| Flag | Purpose |
|---|---|
--host <HOST> |
Bind address (default 127.0.0.1). Non-loopback binds require an explicit token. |
--token <TOKEN> / DYNOXIDE_MCP_AUTH_TOKEN |
Use a fixed token instead of the persisted one. |
--allowed-host <HOST> |
Accept an additional Host header by name (repeatable); needed for non-loopback access by hostname. |
--no-auth |
Disable authentication. Loopback binds only; prints a warning. |
Prefer the environment variable or the persisted file over --token for
anything beyond one-shot debugging, because flag values leak into shell history and
ps. To rotate the token, delete the persisted file (or change
DYNOXIDE_MCP_AUTH_TOKEN) and restart; there is no rotation mechanism by
design.
On the serve subcommand the equivalent flags are prefixed
(--mcp-host, --mcp-token, --mcp-no-auth, --mcp-allowed-host) because
serve already owns --host/--port for the DynamoDB server.
To run the HTTP transport from the container image, see MCP over HTTP in Docker.
HTTP client configuration
Point an HTTP-transport MCP client at the endpoint and send the token in an
Authorization header:
Claude Code configuration
Add to your mcp.json:
Or with a persistent database:
With a OneTable data model for single-table designs:
Available tools (34)
| Category | Tools |
|---|---|
| Tables | list_tables, describe_table, create_table, delete_table, update_table |
| Items | get_item, put_item, update_item, delete_item |
| Batch | batch_get_item, batch_write_item, bulk_put_items |
| Query | query, scan |
| Transactions | transact_get_items, transact_write_items |
| PartiQL | execute_partiql, batch_execute_partiql, execute_transaction_partiql |
| TTL | update_time_to_live, describe_time_to_live, sweep_ttl |
| Tags | tag_resource, untag_resource, list_tags_of_resource |
| Streams | list_streams, describe_stream, get_shard_iterator, get_records |
| Snapshots | create_snapshot, restore_snapshot, list_snapshots, delete_snapshot |
| Info | get_database_info |
Safety options
# Read-only mode - rejects all write operations
# Limit query/scan results
Snapshots
The MCP server supports database snapshots for safe experimentation:
create_snapshot- saves a point-in-time copy of the databaserestore_snapshot- rolls back to a previous snapshotlist_snapshots- lists available snapshots- Auto-snapshot before
delete_table(last 10 kept automatically)
Data Model Context
For single-table designs, raw DynamoDB metadata (pk is type S, GSI1 exists) tells an agent almost nothing. The --data-model flag loads a OneTable schema so the agent sees entity names, key templates, GSI mappings, and type discriminator attributes.
The data model is context-only - dynoxide does not validate writes against the schema. See docs/mcp-data-model.md for the full format reference, options, and examples.
DynamoDB Streams
Dynoxide supports DynamoDB Streams with all four view types: NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES, and KEYS_ONLY.
Enabling streams
Streams are enabled per-table via StreamSpecification in CreateTable or UpdateTable, exactly like real DynamoDB:
# Via AWS CLI
# Enable on an existing table
Via the MCP server, pass stream_specification to create_table or update_table.
Reading stream records
# List streams
# Describe a stream to get shard IDs
# Get a shard iterator and read records
Streams with import
If the --schema file (DescribeTable JSON) contains a StreamSpecification, streams are automatically enabled on the imported table. No extra flags needed. The import faithfully reproduces the source table's configuration:
Note: Imported items do not generate stream records by default (bulk import bypasses stream recording for performance). Stream recording begins for writes made after import completes.
Import CLI
Import data from DynamoDB Export (JSON Lines format) into a Dynoxide database, with optional anonymisation.
Basic import
The --source directory should follow DynamoDB Export structure:
export-data/
├── Users/
│ └── data/
│ └── 00000000.json.gz
└── Orders/
└── data/
└── 00000000.json.gz
The --schema file contains DescribeTable JSON (the output of aws dynamodb describe-table):
Table filtering
Anonymisation
Create a rules file (rules.toml):
[[]]
= "attribute_exists(email)"
= "email"
= { = "fake", = "safe_email" }
[[]]
= "attribute_exists(phone)"
= "phone"
= { = "mask", = 4, = "*" }
[[]]
= "attribute_exists(ssn)"
= "ssn"
= { = "hash", = "ANON_SALT" }
[[]]
= "attribute_exists(notes)"
= "notes"
= { = "redact" }
[]
= ["userId", "email"]
ANON_SALT=my-secret-salt
Action types:
| Action | Description |
|---|---|
fake |
Replace with generated data (safe_email, name, phone_number, address, company_name, sentence, word, first_name, last_name) |
mask |
Keep last N characters, mask the rest (keep_last, mask_char) |
hash |
SHA-256 hash with salt from env var (salt_env, required) |
redact |
Replace with [REDACTED] |
null |
Replace with NULL |
Consistency: Fields listed in [consistency].fields produce the same anonymised value across all tables in a single import run. Same input + same salt = same output.
Options
# Overwrite an existing output file
# Continue importing when a batch fails instead of aborting
# Compress output with zstd
# Produces snapshot.db.zst
Library Usage (Rust)
use Database;
// In-memory (for tests)
let db = memory.unwrap;
// Persistent (backed by SQLite file)
let db = new.unwrap;
// Encrypted (requires `encryption` feature)
// cargo add dynoxide-rs --features encryption
let db = new_encrypted.unwrap;
Operations use DynamoDB-compatible request/response types:
use Database;
use json;
let db = memory.unwrap;
// Create a table
let req = from_value.unwrap;
db.create_table.unwrap;
// Put an item
let req = from_value.unwrap;
db.put_item.unwrap;
// Query
let req = from_value.unwrap;
let resp = db.query.unwrap;
Testing with Embedded Mode
Each test gets a fully isolated database with no shared state:
No Docker. No port conflicts. No table name prefixes. Tests run in parallel without coordination.
Feature Flags
| Flag | Default | Description |
|---|---|---|
native-sqlite |
Yes | Bundles plain SQLite. No OpenSSL. |
http-server |
Yes | Adds axum-based HTTP server exposing the DynamoDB JSON API. |
mcp-server |
Yes | Adds MCP server for coding agents (stdio and Streamable HTTP transports). |
import |
Yes | Adds dynoxide import CLI for importing DynamoDB Export data with anonymisation. |
cli |
Indirect | Gates the dynoxide binary. Pulled in automatically by http-server, mcp-server, or import, so default builds include it; a library-only or wasm-sqlite build omits the binary. |
wasm-sqlite |
No | wasm32 browser backend (wa-sqlite over OPFS), a preview. Pulls neither native SQLite nor the CLI. See the WASM section. |
encryption |
No | Bundles SQLCipher + vendored OpenSSL. Adds Database::new_encrypted() for encryption at rest. |
encryption-cc |
No | Like encryption but uses Apple CommonCrypto instead of bundled OpenSSL. For macOS and iOS builds. |
encrypted-server |
No | Convenience: enables encryption + http-server. |
encrypted-server-cc |
No | Convenience: enables encryption-cc + http-server. |
encrypted-full |
No | Convenience: enables encryption + http-server + mcp-server + import. |
full |
— | Alias for default features (backward compatibility). |
native-sqlite and encryption are mutually exclusive - they select different SQLite backends. To use encryption:
= { = "0.10", = false, = ["encryption"] }
Workspace note: Cargo unifies features across a workspace. If any crate depends on dynoxide-rs with default features (getting native-sqlite) and another uses encryption, both activate and the build fails. Use default-features = false on all dynoxide-rs dependencies in the workspace.
Supported Operations
| Category | Operations |
|---|---|
| Table | CreateTable, DeleteTable, DescribeTable, ListTables, UpdateTable |
| Item | PutItem, GetItem, DeleteItem, UpdateItem |
| Query & Scan | Query, Scan |
| Batch | BatchGetItem, BatchWriteItem |
| Transactions | TransactWriteItems, TransactGetItems |
| PartiQL | ExecuteStatement, BatchExecuteStatement, ExecuteTransaction |
| Streams | DescribeStream, GetShardIterator, GetRecords, ListStreams |
| TTL | UpdateTimeToLive, DescribeTimeToLive |
| Tags | TagResource, UntagResource, ListTagsOfResource |
Expression Support
- KeyConditionExpression
- FilterExpression
- ConditionExpression (attribute_exists, attribute_not_exists, begins_with, contains, size, between, in)
- ProjectionExpression
- UpdateExpression (SET, REMOVE, ADD, DELETE)
Additional Features
- Global Secondary Indexes (GSI)
- DynamoDB Streams (NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES, KEYS_ONLY)
- TTL with background sweep
- ReturnConsumedCapacity (TOTAL and INDEXES)
- ReturnValuesOnConditionCheckFailure
- ClientRequestToken idempotency for TransactWriteItems
- PartiQL SELECT, INSERT, UPDATE, DELETE with EXISTS/BEGINS_WITH functions
- Pagination with LastEvaluatedKey/ExclusiveStartKey (1MB page limit)
- Item size validation (400KB limit)
- Transaction size validation (4MB aggregate, 100 action limit)
- Batch size limits (16MB response, 100 keys for get, 25 items for write)
Acknowledgements
Dynoxide's DynamoDB API semantics and validation logic were informed by dynalite, the excellent DynamoDB emulator built on LevelDB by Michael Hart and now maintained by the Architect team.
Dynoxide is a clean-room Rust implementation. No code was ported directly, but dynalite's thorough approach to matching live DynamoDB behaviour, including edge cases and error messages, was an invaluable reference.
Dynoxide uses SQLite as its storage layer. (AWS's DynamoDB Local also uses SQLite internally.)
License
Dual-licensed under MIT and Apache 2.0. See LICENSE-MIT and LICENSE-APACHE.