Two reasons, always the same.
Update (Sept 2026): parts of this playbook referred to tooling that does not exist, and those steps have been corrected. There is no ddb-importer image and no DynamoDB Streams sidecar. The bulk-load step now converts the DynamoDB export to JSONL and loads it with the repo's veltrix-migrate import. The cost (3–5×) and latency (12 ms → 4.9 ms) comparisons have no measured source and have been removed. The 4.9 ms figure comes from an internal, unverified run. VeltrixDB is self-hosted only; there is no managed service today. It speaks its own binary and text protocols, not RESP or the DynamoDB API, so your application has to use a VeltrixDB client. Claims about earlier customer migrations (counts, durations, zero rollbacks) could not be sourced and have been removed (Oct 2026).
Every team that walked us through a DynamoDB-to-VeltrixDB migration showed up with the same two concerns. The bill, and the tail.
If you're reading this guide, you probably already know your why. For the engineering manager you'll be selling this migration to, make the case with your own numbers:
- Cost · take your current DynamoDB invoice and compare it with the cost of self-hosted nodes with local NVMe sized for your RAM and disk needs. VeltrixDB keeps every key in RAM. With small values the index (~142 B/key, plus ~90 B/key for the ordered index unless disabled) is about as large as the data, so size RAM from your key count. The node-sizing rules give an estimate, not a measurement.
- Tail latency · measure DynamoDB's P99 for your access pattern, then measure VeltrixDB's during the shadow-read phase (§06). The only reproducible VeltrixDB read figure we publish is a cache-resident YCSB run (P99 2.77 ms). Its conditions are in the benchmark post.
The rest of this guide is the playbook. Two sprints. No big-bang cutover. Rollback at every stage.
Confirm you're in scope — before you book the project.
VeltrixDB is a scalpel, not a Swiss Army knife. Spend an hour validating fit before you spend two sprints validating performance.
Before you start, run the table through this checklist. If any row is a "no," talk to us before booking the migration — there's a good chance VeltrixDB is the wrong tool for that workload.
- Your hot path is point lookups — GET / PUT / DEL / HGET-style. Ordered range scans exist (
RANGE,SCANCUR), but in cluster modes they read only the local node (and on v1.13 and earlier skip the Raft read barrier; Fixed in v1.14: they pass it). So a critical path built onQuerywith sort-key ranges, or onScan, needs a careful look. - GSI access patterns either become separate keys, or a secondary index on a record field (
IDXCREATE name field/IDXQUERY name value). The field is read from a JSON ork=vvalue, so store items as flat JSON (unmarshal DynamoDB JSON first).IDXQUERYfans out to every node in a cluster; an index is equality-only. - You have a partition key in DynamoDB that can map 1:1 to a VeltrixDB key. Composite (PK + SK) keys map to
pk:skstring concatenations. - Values are ≤ 400 KB (DynamoDB's own limit). VeltrixDB supports up to 64 MB, but smaller values keep your RAM budget honest.
- You can run on bare-metal NVMe or local SSD. That is what the engine is designed and benchmarked for. Network-attached block storage (EBS, Azure managed disks) will run, but every
fdatasyncis then a network round trip, and nothing has been measured there.
If you're using DynamoDB Streams · we don't ship a Streams consumer. The dual-write phase (§05) keeps VeltrixDB current without one. If you'd rather mirror from Streams, write a small consumer (for example a Lambda) that applies each change with a VeltrixDB client. On the VeltrixDB side, CDC is available at /admin/cdc, and repl-ship forwards it to another VeltrixDB cluster.
Two sprints. Five stages.
No big-bang cutover. Every stage runs DynamoDB in parallel until the very last hour. Rollback is one config flip away at every checkpoint.
:2112. Validate health checks against an empty cluster. No production traffic yet.ExportTableToPointInTime to S3, convert the export to JSONL, and pipe it into veltrix-migrate import. We have no measured import rate, so time a sample before you plan the window. Validate row count and a sample of values match (veltrix-migrate export dumps the same JSONL back).Terraform you can paste into your repo.
Three resources, eight inputs. Drop into your existing EKS module, change the instance type to a local-NVMe SKU, run terraform apply.
This snippet provisions a 3-node EKS-hosted VeltrixDB cluster on i3en.6xlarge instances with local NVMe. Adjust node count and instance class, or swap the EKS parts for GKE/AKS modules. The Helm chart is not in the main VeltrixDB repo. Check the chart repository listed in the VeltrixDB README and pin a chart version that exists there. Your values.yaml.tpl has to use that chart's own value names.
# Helm release wrapping the VeltrixDB chart resource "helm_release" "veltrixdb" { name = "veltrixdb" repository = "https://charts.veltrixdb.io" chart = "veltrixdb" # version = "…" pin a chart version published in that repository namespace = "veltrixdb" create_namespace = true values = [templatefile("./values.yaml.tpl", { nodes = 3 cache_mb = 131072 # server flag -cache is in MB (default 256) nvme_gb = 3000 storage_class = "veltrixdb-nvme" region = "us-east-1" })] } # Local NVMe StorageClass (i3en family) resource "kubernetes_storage_class" "nvme" { metadata { name = "veltrixdb-nvme" } storage_provisioner = "kubernetes.io/no-provisioner" volume_binding_mode = "WaitForFirstConsumer" parameters = { type = "local-ssd" fsType = "xfs" # nvme-prep formats local SSDs as XFS } } # Node group: i3en.6xlarge — 24 vCPU, 192 GiB RAM, 2 × 7.5 TB local NVMe module "veltrix_nodes" { source = "terraform-aws-modules/eks/aws//modules/eks-managed-node-group" cluster_name = var.eks_cluster_name name = "veltrixdb-nodes" instance_types = ["i3en.6xlarge"] desired_size = 3 min_size = 3 max_size = 6 labels = { workload = "veltrixdb" } taints = [{ key = "workload", value = "veltrixdb", effect = "NO_SCHEDULE" }] }
Bulk-import from DynamoDB
There is no dedicated DynamoDB importer. The repo's veltrix-migrate import reads JSONL from stdin, one {"k": "<key>", "v": "<base64 value>"} per line, and PUTs each entry over the binary protocol. It's idempotent: re-runs overwrite, so it's safe to restart if it fails partway. The jq step below maps each DynamoDB item to a key (here, a partition key pk of string type) and stores the item's JSON as the value. Adapt it to your key schema, for example pk:sk for composite keys. It sends one PUT at a time and does not carry TTLs. Each single PUT takes a whole 4 KB VLog block, so for many small items a loader that batches with MPUT (e.g. the Go client's BinaryConn.MPut) packs ~26 × 128 B records per block instead. In --mode=raft, point --dst at the current leader, because followers reject writes with a MOVED redirect and veltrix-migrate does not follow it. In --mode=replicated any node accepts the write and replicates it to its peers (eventual by default).
$ aws dynamodb export-table-to-point-in-time \ --table-arn arn:aws:dynamodb:us-east-1:123456789012:table/sessions \ --s3-bucket veltrix-migration-staging \ --export-format DYNAMODB_JSON $ go build -o veltrix-migrate ./cmd/veltrix-migrate # from the VeltrixDB repo $ aws s3 cp --recursive \ s3://veltrix-migration-staging/AWSDynamoDB/<export-id>/data/ ./export/ $ gunzip -c ./export/*.json.gz \ | jq -c '{k: .Item.pk.S, v: (.Item | tojson | @base64)}' \ | ./veltrix-migrate import --dst veltrixdb-0.veltrixdb:9000
Fan out writes, keep DynamoDB as the source of truth.
During sprint 1, every write goes to both databases synchronously. If divergence climbs above 0.1%, an alarm fires and you fix it before you ever flip reads.
The dual-write step is where most botched migrations go wrong. The temptation is to fan out writes asynchronously to keep latency down — don't. Async fan-out hides bugs that only surface during cutover. Run the writes synchronously, in parallel, and accept the latency cost during the migration window.
Reference implementation
The pattern, in pseudocode, looks like this. The key invariants: write to DynamoDB first (still source of truth), then write to VeltrixDB, then log any inconsistencies — but never roll back a successful DynamoDB write because the VeltrixDB write failed.
func (r *Repo) Put(ctx context.Context, k string, v []byte) error { // 1. Source of truth still wins if err := r.ddb.Put(ctx, k, v); err != nil { return err } // 2. Mirror to VeltrixDB · best-effort go func() { cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() if err := r.veltrix.Put(cctx, k, v); err != nil { metrics.DualWriteFail.WithLabelValues("veltrix").Inc() log.Warn("veltrix mirror failed", "key", k, "err", err) } }() return nil }
Watch the divergence metric. A healthy dual-write phase should see <0.01% mirror failures, almost entirely network blips. If you see >0.1%, something is wrong — most often an encoder that doesn't round-trip a value byte-for-byte (VeltrixDB accepts values up to 64 MB, well above DynamoDB's 400 KB item limit). Fix before proceeding.
Read both. Trust neither. Compare everything.
Before flipping the read path, you spend 48 hours reading from both databases and comparing the answers byte-for-byte. This is where you catch the bugs that bulk-import didn't.
The shadow-read phase runs on the same dual-write pattern, inverted. For every read your application makes, fire a second read against VeltrixDB, compare, log mismatches, and surface the side-by-side P99 chart in Grafana. Your users still see DynamoDB's answer — VeltrixDB is on probation.
- Mismatch rate
< 0.001%over a full weekend cycle (catches any time-zone or weekend-batch effects) - VeltrixDB P99 read latency
< 8msfor 4 hours straight under peak prod load - Cache hit rate
> 90%in steady state — the LIRS cache should be warm by now - Zero errors from the VeltrixDB client during the window (VeltrixDB speaks TCP, not HTTP, so there are no
5xxcodes to count) - An incident runbook merged that documents the rollback procedure for the next stage
5 → 25 → 50 → 100. No big-bang flips.
Move the read traffic in four feature-flag controlled steps. Each step must be stable for four hours before the next. Rollback at any step is a single config change.
If shadow reads were clean this stage should be uneventful — walk through the cohorts anyway. Discipline here is what makes the postmortem boring.
read_source=veltrix applied to a 5% user-id hash range. Watch P99, error rate, customer-support tickets. No deploys during this window.Rollback
If anything goes wrong at any step, the rollback is a single feature-flag flip: read_source=dynamodb. Because writes are still going to both databases, DynamoDB never went stale. You lose nothing, and you debug at leisure.
Clean shadow reads are what make this step boring. Typical problems — a serialization quirk, a Unicode normalization edge case — surface during the 48-hour shadow window, where they cost nothing to fix. That's the entire point of running shadow-reads.
Decommission, save the receipts.
A week after cutover, drop the dual-write fan-out and put the DynamoDB table into read-only mode. Keep it warm for a month as cold storage. Then archive it.
The decommission step is procedural — no surprises. Drop the dual_writer.go path from your repo, point the DynamoDB IAM role at a read-only policy, and schedule a calendar reminder for 30 days out to delete the table. Most teams keep the table around longer than they need to — that's fine, and the cost is trivial.
What's not trivial: save the DynamoDB invoice from the month before cutover. Compare it to the VeltrixDB invoice three months later. Remember that VeltrixDB's side of that comparison is your own infrastructure bill, since it is self-hosted. The delta is the receipt you bring to the next budget review, and the number your CFO will want to see in writing. (An earlier version quoted customer savings figures that we cannot source, so we have removed them.)
cmd/veltrix-migrate, client/) · Oct 2026 · durations are a plan, not measurements.