Run PostgreSQL on your own Linux box with the ergonomics of a managed service.
ODDK is a single Go binary that manages PostgreSQL the way a cloud provider's managed database does — create an instance, get a connection string, take scheduled snapshots, ship them offsite to S3, watch health, restore on demand, upgrade major versions — except it all runs locally against Docker, on hardware you control. Think "a small, self-hosted RDS for Postgres."
oddk create --name app --version 17 --port 5432 --cpu 4 --ram 8 # pulls the image if needed
oddk instance get-postgres-password app --conn
# postgresql://postgres:••••••••@10.88.0.1:5432/postgres- A local "managed Postgres" control plane. One daemon + CLI that owns the full lifecycle of PostgreSQL instances running as Docker containers.
- Opinionated and batteries-included. Sensible defaults for resources, shared memory, networking, and tuning — plus AWS-style parameter groups when you want to override them.
- Operationally complete. Whole-deployment snapshots — scheduled, shipped to S3, and able to rebuild a single instance or an entire host — plus health monitoring with Email/Slack/Telegram/Webhook alerts, password and user management, minor-version image switches, dump/restore major upgrades, and legacy per-instance backups (still the way to restore a single database).
- Actually recoverable. A snapshot carries every instance's data and ODDK's own configuration, so a dead host can be rebuilt from one archive plus the master key — not reassembled by hand from per-database dumps.
- Secure by default for a single host. Secrets encrypted at rest, a loopback-only API behind a bearer token, and Postgres bound to a host-local bridge — not the public internet.
- Single binary, no runtime dependencies beyond Docker. Pure Go, builds static, installs in seconds.
- Not a high-availability / clustering / replication manager. No failover, no streaming replicas, no quorum. It runs standalone instances well.
- Not a multi-tenant hosted service. It assumes a single trusted operator on a single host. Anyone with the API token has admin-equivalent control.
- Not an internet-facing database gateway. The API binds to
127.0.0.1and Postgres binds to a host-local Docker bridge. Reach them over an SSH tunnel, not by exposing ports. - Not a Postgres fork, driver, or connection pooler. It orchestrates the
official PostgreSQL images (and compatible ones like
pgvector/postgis); it doesn't replace your client library or PgBouncer. - Not a Kubernetes operator. It talks to the Docker API directly. If you're on Kubernetes, use an operator instead.
- Not something you run inside Docker. ODDK manages and monitors Docker from the host — it is the control plane, not a workload. See Run ODDK on the host, not inside a container.
- Not for Windows or production macOS. Linux is the deployment target; macOS is supported for development only.
If you've ever wanted RDS-style convenience — "give me a database, back it up,
tell me when it's unhealthy, let me restore it" — without the cloud bill, the
network exposure, or hand-rolling docker run + pg_dump + cron + a monitoring
script, ODDK is that, as one tool with one mental model.
| You want… | ODDK gives you… |
|---|---|
| A new database, fast | oddk create → ready-to-use Postgres with a connection string |
| Confidence it's backed up | snapshot make, one scheduled snapshot covering everything, S3 offsite with retention |
| To not lose data | snapshot restore-instance (one instance) or snapshot apply (a whole host) |
| To know when it breaks | Health checks + degraded/restored notifications |
| To tune Postgres safely | AWS-style parameter groups with expression evaluation |
| To move to a new major | instance major-upgrade via dump/restore |
| Secrets handled properly | AES-256-GCM-encrypted passwords, tokenized API auth |
- Linux (x86_64 or arm64)
- Docker (running)
- systemd (for the installed service)
ODDK is a Docker control plane. Run it on the host, directly on the machine that runs Docker — never inside a container.
The whole point of ODDK is to manage and monitor Docker: it creates and
destroys PostgreSQL containers, attaches them to a host bridge network, reads
host disk/CPU/memory for health checks, and writes state and snapshot/backup
archives to host paths. That is the opposite of being a containerized workload itself. Running
ODDK inside Docker inverts the relationship and breaks its assumptions —
host-level resource metrics, the 10.88.0.0/16 bridge and 10.88.0.1 gateway
binding, data/backup paths, and the systemd service lifecycle all expect a host
process. Bind-mounting the Docker socket into a container to work around this is
exactly the inversion ODDK is designed to avoid, and is not supported.
If what you actually want is to run a database inside Docker/Compose as part of a containerized stack, that is a different problem with different tools — use Docker Compose, a Kubernetes operator, or your platform's managed database instead. ODDK is for owning the host and treating Docker as the thing it drives.
On a Linux server with Docker and systemd, install (or update) the latest release:
curl -fsSL https://raw.githubusercontent.com/andrianbdn/oddk/main/install.sh | shPin a specific version:
curl -fsSL https://raw.githubusercontent.com/andrianbdn/oddk/main/install.sh | sh -s -- --version v0.1.39The installer downloads the release binary from GitHub, verifies it against the
published SHA256SUMS, and:
- installs the binary to
/usr/local/bin/oddk - creates a dedicated
oddkservice user (no login shell) with state under/var/lib/oddk(data/,backups/) - installs and starts a systemd unit (
oddk.service) - configures the CLI for the user who ran the installer, writing
~/.config/oddk/cli.json
That last step means the person who runs the installer can use oddk right
away — no sudo, no becoming the oddk user.
Installing and updating use the same command. Re-run the curl installer at
any time — on an existing install it detects the service, swaps the binary in
place, restarts, and keeps the previous binary as oddk.prev for instant
rollback. There is no separate update step.
Before updating, skim CHANGELOG.md. Most releases need nothing from you, but the entries marked Action do — a credential to rotate, or a schedule to resume after a disaster-recovery restore.
The CLI authenticates to the daemon with a bearer token. To set up oddk for an
additional user, mint a token and install their config in one step:
eval "$(sudo -u oddk /usr/local/bin/oddk auth mint)"The plaintext token is shown only when created and cannot be read back later. If you lose it, mint a new one with
oddk auth mint. Useoddk auth mint --jsonto print the config instead of eval-able shell,oddk auth listto see existing tokens, andoddk auth delete <id>to revoke one.
After installation the daemon is running and your CLI is configured. From here:
# 1. Create an instance — 4 CPUs, 8 GB RAM, listening on port 5432.
# The PostgreSQL image is pulled automatically if it isn't already local.
oddk create --name app --version 17 --port 5432 --cpu 4 --ram 8
# 2. See what you have
oddk list
# 3. Get connection details (password is auto-generated, encrypted at rest)
oddk instance get-postgres-password app --conn # full connection string
eval "$(oddk instance get-postgres-password app --envs)" # export PG* env vars
# 4. Open a psql shell
oddk instance psql appConnecting from the host:
postgresql://postgres:PASSWORD@10.88.0.1:<port>/postgres
Connecting from another Docker container (e.g. your app's docker-compose.yml):
extra_hosts:
- "host.docker.internal:host-gateway"
# then: postgresql://postgres:PASSWORD@host.docker.internal:<port>/postgresoddk is organized into subcommands. Everything below has --help
(oddk instance --help, oddk snapshot --help, …).
oddk create --name app --version 17 --port 5432 --cpu 4 --ram 8
oddk create --name dev --version 17 --port 5433 --cpu 1 --ram 1024M # RAM accepts M/MB/MiB
oddk instance status app
oddk instance start app
oddk instance stop app
oddk instance logs app --follow
oddk instance destroy app
oddk list # all instances at a glance
oddk checklist # audit overview, one detailed block per instance: health,
# parameter group, and snapshot coverage (is this instance's
# data in the newest snapshot?); plus global snapshot and
# notification status
oddk checklist --json # same data as JSONCreate/start/switch/reconfigure block until Postgres actually accepts connections before reporting success, so a command never returns "running" while the server is still coming up.
Deploying a new service? One command creates the database and its owner user (rolling back the user if database creation fails), and prints the generated password and a ready-to-paste connection string. The user owns the database, so migrations just work:
oddk instance create-db app --database billing --username billing # DB + owner userThe pieces are also available separately (read-only and extra users are added
with add-db-user once the database exists):
oddk instance create-db app --database analytics
oddk instance list-dbs app
oddk instance add-db-user app --username appuser --database analytics # read-write
oddk instance add-db-user app --username reader --database analytics --readonly # read-only
oddk instance add-db-user app --username appuser --database analytics --owner # owner (runs migrations)
oddk instance reset-db-user-password app --username appuser
oddk instance delete-db-user app --username appuserdelete-db-user first reassigns everything the user owns to postgres, then
revokes its grants and drops the role. If that reassignment fails the command
aborts and leaves the user in place — it will not proceed to a step that would
drop the objects instead of the grants. On a very large database the reassign
can exhaust the shared lock table (out of shared memory ... increase max_locks_per_transaction); raise max_locks_per_transaction with a parameter
group and retry.
oddk instance get-postgres-password app # structured details
oddk instance get-postgres-password app --plain # just the password
oddk instance get-postgres-password app --conn # connection string
NEW_PGPASSWORD=secret oddk instance set-postgres-password appA snapshot captures everything: every instance's databases and roles, plus ODDK's own configuration, in one archive. It is what a host migration or a real disaster recovery restores from. A per-instance backup cannot do that — it holds one instance's data and none of the configuration needed to rebuild it.
# Capture the whole deployment (physical/binary by default — see below)
oddk snapshot make --comment "before major upgrade"
oddk snapshot make --logical # portable pg_dump-based format
oddk snapshot list
# Suspend a schedule without losing it (and put it back)
oddk snapshot setup-cron --pause
oddk snapshot setup-cron --resume
# Schedule it. One schedule per deployment — a snapshot covers every instance.
oddk snapshot setup-cron --utc-hour 3 # daily at 03:00 UTC
oddk snapshot setup-cron --utc-hour 3 --interval-hours 6 # 03,09,15,21 UTC
oddk snapshot setup-cron --utc-hour 3 --logical # schedule portable snapshots
oddk snapshot list-cron
# Already scheduling per-instance backups? Move those schedules over in one step.
oddk snapshot migrate-from-backups --dry-run # preview; changes nothing
oddk snapshot migrate-from-backups --yes
# Offsite (requires `oddk offsite apply`, below)
oddk snapshot upload <id>
oddk snapshot download <id>
oddk snapshot remove-local <id>
oddk snapshot remove-remote <id>
# Did last night's scheduled work actually run?
oddk cron logs # last 20 runs: backups and snapshots, one row each
oddk cron logs --failures # only runs with a failed phase
oddk cron logs --instance appOne instance failing does not cost the others their archive. If an instance
cannot be captured — its server is unreachable, its stored password no longer
authenticates, PostgreSQL is out of WAL senders, its container has vanished — that
instance is recorded in the snapshot as configuration-only and everything else is
captured normally. The
archive is kept, uploaded and catalogued, because it is still the newest restore
point for every other instance. But the run reports failure: snapshot make
exits non-zero and names the instance, a scheduled run's capture phase is marked
failed and notifies, and oddk checklist shows that instance as ✗ config-only
rather than covered. An archive that is empty for somebody must never read as
protection.
Restoring comes in three shapes:
# 1. Rebuild ONE instance into a deployment that stays up, from a local file.
# Creates it if it is gone; replaces its data if it is still there.
oddk snapshot restore-instance --instance app --file snapshot-db01-20260729140312.tar.zst
# 2. The same, straight from S3 — no manual download step.
oddk snapshot restore-instance --instance app --id 7 # this host's catalogue; downloads
# from S3 if the local copy is gone
oddk snapshot restore-instance --instance app \
--s3-uri 's3://bucket/oddk-backups/*snapshots*/2026-07-29/snapshot-db01-20260729140312.tar.zst' \
--master-key /mnt/restore/master.key # another deployment's snapshot
# Find URIs with `oddk snapshot list-remote` — it lists what is actually in the
# bucket, including snapshots whose records died with another host.
# 3. Rebuild a WHOLE HOST — migration or disaster recovery.
# Runs locally, not through the daemon, so it works when the daemon cannot start.
systemctl stop oddk
sudo -u oddk oddk snapshot apply \
--file /mnt/restore/snapshot-db01-20260729140312.tar.zst \
--master-key /mnt/restore/master.key
systemctl start oddk
# apply PAUSES every restored schedule. Resume once this host owns its bucket:
oddk snapshot setup-cron --resume
# apply can also fetch the archive itself, using this shell's AWS credentials —
# see "Disaster recovery from S3" below for the full walkthrough.
sudo -u oddk oddk snapshot apply \
--s3-uri 's3://bucket/oddk-backups/*snapshots*/2026-07-29/snapshot-db01-20260729140312.tar.zst' \
--master-key /mnt/restore/master.keyWhat you need to know:
- Snapshots are physical (binary) by default. Each running instance is
captured with
pg_basebackup— fast, gentle on a busy server, and byte-for-byte faithful (per-database settings, database-level privileges and ICU collations all survive, which the logical format cannot promise). A physical snapshot restores onto the same PostgreSQL major and the same CPU architecture;--logicalproduces the portablepg_dump-based format for cross-architecture moves and for UNLOGGED table rows that a physical restore would empty. Restore a single database withoddk backup restore --database, not a snapshot command. - UNLOGGED tables come back empty from a physical restore. This is standard
physical-backup semantics (RDS storage snapshots behave the same): unlogged
tables are truncated by any crash recovery, which is what a physical restore
performs — the trade you accept for their WAL-free write speed. If an
unlogged table's contents must survive a restore, either make it a normal
table or use
--logical, which dumps its rows. - Back up
master.keyseparately. It is deliberately not in the archive, and a snapshot cannot be applied without it. - Snapshots are not encrypted, and they carry more than your data. The
archive holds database contents and role password hashes in plaintext, and it
embeds
oddk.dbverbatim. The master key encrypts three columns in there — each instance's postgres password, the S3 secret access key, and (since 0.1.80) every notification config. Everything else in that database is in the clear. So whoever can read a snapshot can read your databases, and that includes anyone with read access to the offsite bucket you upload to; restrict it accordingly. Archives written before 0.1.80 also carry your notification credentials in the clear — the SMTP password, the Slack webhook URL, the Telegram bot token, and anyAuthorizationheader on a webhook. Encrypting the column going forward does not unpublish what already shipped: if an older archive has been anywhere you would not put a password, rotate those credentials. - What gets captured is decided by the container, not by recorded state. A
snapshot asks Docker what each container is actually doing at capture time, so
an instance whose recorded status has drifted is still captured correctly. A
stopped container is captured as a cold copy of its data directory (and
restored back to a stopped instance); only with
--logical— which needs a live server to dump — is it reduced to configuration-only. When an instance should have been capturable but was not — its container has vanished, its state cannot be read, or it is paused or restarting — the run is reported as failed, because the data is still on this host (ODDK keeps it in a Docker volume that removing a container does not delete) and the archive was written without it. Only the cases where there is nothing to read fall back to a quiet configuration-only entry: an instance with no container at all, or one you deliberately stopped under--logical. Either way the specific reason is recorded in the manifest and printed on stdout — reported, never silent. - Restoring an instance sets its postgres password to the snapshot's, because the
archive carries only the hash. Re-read it with
instance get-postgres-password. - Retention keeps the newest snapshots regardless of age, so a run of failed captures can never expire everything you have.
- Offsite upload is currently limited to 5 GiB per snapshot (a single S3
PutObject; no multipart yet).
oddk checklist reports whether snapshots are scheduled, how stale the newest one
is, and — per instance — whether that instance's data is actually in the newest
snapshot: an instance captured configuration-only (e.g. stopped during a logical
capture) is flagged rather than counted as protected, and an instance created
after the newest snapshot reads "not yet captured" until the next run. Per-instance
backups are legacy and no longer appear in the audit, except as a warning when an
instance still has an un-migrated backup schedule.
oddk snapshot migrate-from-backups turns your per-instance backup schedules
into the single deployment-wide snapshot schedule and then removes them. It picks
the most common hour and the longest retention window any schedule used, so
nothing is silently shortened; --utc-hour, --interval-hours and the two
--cleanup-*-days flags override the derived values. Offsite settings are global
and already shared by both paths, so there is nothing there to move.
Snapshots are scheduled before the backup schedules are removed, so an
interrupted run leaves both active rather than neither. Re-running on an
already-migrated host is a quiet success, and an existing snapshot schedule is
kept rather than overwritten unless you pass an override flag — both make it safe
to run across a fleet. Add --dry-run to preview, --yes to skip the prompt, and
--json (with either) for scripted rollouts.
Your existing backups are kept, but they stop being pruned. Age-based cleanup only ever runs from a backup schedule, so removing the schedule ends it permanently. The command reports how many archives and how much disk this leaves behind. They stay restorable with
oddk backup restore; remove them withoddk backup remove-localonce you trust the snapshot schedule — or all at once withoddk backup dangerously-drop-all(below).
This is a transitional command. Per-instance backup itself is unaffected — it
is still the only way to restore or clone a single database.
A dead host's snapshots live in the bucket; the replacement host starts with
nothing — no ODDK state, no CLI token, no AWS tooling. snapshot list-remote
and snapshot apply --s3-uri are built for exactly that: both run without a
daemon and without a token, using the plain AWS credentials of the shell they
run in, so you never have to install and configure a second S3 tool mid-outage.
1. Install ODDK on the new host (the same curl installer as above). It
starts a fresh empty daemon — that's fine, apply handles the fresh-install
collision itself.
2. Get AWS credentials to the oddk user, who runs the apply. Three
options, best first:
-
EC2 instance role — zero configuration. If the host's role can read the bucket,
sudo -u oddk oddk snapshot apply ...just works. -
Environment variables —
sudostripsAWS_*from the environment, so pass them through explicitly (never put secrets on the command line itself — they would be visible in the process list):sudo --preserve-env=AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN,AWS_REGION \ -u oddk oddk snapshot apply --s3-uri '...' --master-key /mnt/restore/master.key -
A credentials file for the
oddkuser — write a standard[default]credentials file with your editor, install it, and destroy both copies when done:sudo install -d -o oddk -m 700 ~oddk/.aws sudo install -o oddk -m 600 /tmp/creds ~oddk/.aws/credentials && shred -u /tmp/creds # ... run the restore ... sudo shred -u ~oddk/.aws/credentials
3. Find the snapshot. With an explicit URI, list-remote lists the bucket
directly — newest first, with ready-to-paste URIs:
oddk snapshot list-remote s3://my-backup-bucket/oddk-backups/4. Apply it, start, audit:
systemctl stop oddk
sudo -u oddk oddk snapshot apply \
--s3-uri 's3://my-backup-bucket/oddk-backups/*snapshots*/2026-07-29/snapshot-db01-20260729140312.tar.zst' \
--master-key /mnt/restore/master.key
systemctl start oddk
oddk checklistThe archive is downloaded into a managed downloads/ area under the backup
directory before anything is touched — a failed apply keeps it there for the
retry (re-running with the same --s3-uri reuses it), and it is pruned
automatically after 7 days.
applypauses every restored schedule, and that is on purpose. The archive carries the source host'soddk.db, which holds its snapshot schedule, its per-instance backup schedules and its offsite bucket and credentials. Starting the daemon unpaused would put two hosts on one bucket: this one would upload under the same layout and — the dangerous half — run offsite retention against it, deleting archives the source still catalogues. Sinceapplycannot know whether the source is alive, it fails safe.So a restored host takes no snapshots until you resume. That is reported three ways so it cannot be forgotten:
applyprints it,oddk checklistshows✗ ... PAUSED, NO SNAPSHOTS ARE BEING TAKEN, and a notification repeats daily until it is resumed or removed.oddk snapshot setup-cron --resume # the snapshot schedule oddk backup setup-cron --instance <name> --resume # any legacy backup scheduleIf this is a rehearsal and the source is still live, leave them paused — or point this host at its own bucket with
oddk offsite applyfirst.If the source host is gone and this one replaces it,
--no-pause-schedulesskips the pause so the replacement is protected from its first scheduled run rather than from whenever somebody reads the checklist. Only use it when you know nothing else writes to that bucket — that is the one factapplycannot check for you. It leaves the schedules as the archive carried them, so a plan the source had already paused stays paused.
You still need
master.key, and it is deliberately not in the bucket — an archive and its key stored together would defeat the encryption of the secrets inside. And remember that snapshots themselves are not encrypted: they hold database contents and role password hashes in plaintext, so guard bucket access accordingly. (Notification credentials are encrypted since 0.1.80; older archives carry them in the clear.)
One S3 configuration serves the whole deployment — snapshot uploads and legacy backup uploads alike:
oddk offsite apply --file offsite.json # see `oddk offsite get` for the template
oddk offsite testThe configuration JSON (oddk offsite get prints a template when none is
configured):
{
"type": "s3",
"bucket": "my-backup-bucket",
"region": "us-east-1",
"accessKeyId": "YOUR_ACCESS_KEY_ID",
"secretAccessKey": "YOUR_SECRET_ACCESS_KEY",
"bucketPath": "oddk-backups/",
"ec2IamRole": false
}type— only"s3"is supported.bucket— required.region/endpoint— at least one must be set, so requests are never signed for a guessed location. Setendpoint(anhttp(s)URL) for S3-compatible storage; it also switches the client to path-style addressing, which most compatible services require.accessKeyId/secretAccessKey— required unlessec2IamRoleistrue. The secret is encrypted at rest with the master key; on lateroffsite getcalls it is shown as a placeholder, never echoed back.ec2IamRole— settrue(and leave both keys empty) to authenticate with the host's EC2 instance role instead, so no long-lived secret exists on disk at all.bucketPath— optional key prefix. It must end with/(e.g.oddk-backups/), must not start with/, and may not contain//or./..segments — so a malformed prefix can't scatter uploads across the bucket root. Empty means the bucket root.
When offsite is configured, each scheduled snapshot run uploads the new snapshot, retries earlier failed uploads, and then applies retention — and local retention never deletes an archive whose only copy is local.
Retention keeps the newest two snapshots regardless of age, plus the newest
complete one (every instance captured with data), so a run of degraded
captures cannot expire the last fully restorable archive. That extra pin is
bounded: it holds for your retention window plus 30 days, then releases with a
warning naming oddk checklist. A deployment with a permanently
configuration-only instance never produces another complete archive, and an
unbounded pin would keep that one on disk and in your bucket forever while the
checklist quietly reported the instance as uncovered.
The same two safeguards apply to scheduled backups: a local backup with no remote copy is never aged out while offsite is configured, and retention (local and offsite) always keeps the newest two regardless of age, so a job that has been failing longer than its retention window cannot expire everything you have.
Stored settings and ambient credentials are two separate paths. Everything
the daemon does offsite — scheduled uploads, snapshot upload/download,
the zero-argument snapshot list-remote, and restore-instance --id — uses
the stored configuration above. The daemon-less commands
(snapshot list-remote s3://... and snapshot apply --s3-uri) instead use
whatever AWS credentials their shell has (env vars, an ~/.aws profile, an EC2
instance role), because a disaster-recovery host has no stored settings yet —
they live inside the snapshot it is trying to restore.
snapshot restore-instance --s3-uri bridges the two: the CLI resolves your
shell's credentials and passes them along, and the daemon prefers its own
offsite settings whenever the bucket matches.
Archives fetched by URI land in a managed downloads/ area under the backup
directory and are pruned after 7 days; everything there is re-fetchable from
S3, so deleting it never loses data.
Snapshots are the recommended way to protect a deployment, and the only protection the
oddk checklistaudit reports. Per-instance backups keep working — and are still the only way to restore or clone a single database, which snapshots cannot yet do — but don't build new automation on them; move schedules over withoddk snapshot migrate-from-backups.
oddk backup make app --comment "before deploy"
oddk backup list --instance app
oddk backup restore --instance app --id 42 --database analytics
oddk backup restore --instance app --id 42 --database analytics --restore-as analytics_copy
oddk backup restore --instance app --file /path/to/backup.tar.zst --database analytics
# Scheduling and offsite copies (superseded by `oddk snapshot setup-cron`)
oddk backup setup-cron --instance app --utc-hour 3 # daily at 03:00 UTC
oddk backup setup-cron --instance app --cleanup-local-days 14 # keeps the existing hour
oddk backup setup-cron --instance app --pause # suspend without deleting
oddk backup setup-cron --instance app --resume
oddk backup list-cron # STATUS column: active / PAUSED
oddk backup upload app <backup-id>
oddk backup download app <backup-id>Fields you do not pass to setup-cron are preserved, matching
oddk snapshot setup-cron — changing a retention window cannot silently move
the hour. --utc-hour is therefore required when creating a schedule, and
optional when adjusting one.
Backups record roles with database-level CREATE access, and both restore and
major-upgrade reapply those grants automatically. A role must already exist on
the target instance to receive its grant; missing roles are reported and skipped
without failing the operation. Older archives without this metadata retain the
previous behavior.
Done with per-instance backups? Once the snapshot schedule has proven itself, delete every leftover backup in one sweep — local archives, S3 copies, and the whole backup history, including backups of instances that no longer exist. Snapshots are untouched:
oddk backup dangerously-drop-all # preview — changes nothing
oddk backup dangerously-drop-all --apply # delete (asks for confirmation)The preview warns if any instance still has a backup schedule (migrate it first — the next cron run would just create new backups) and if no snapshot exists yet, in which case these backups are the only thing a restore could use.
oddk create --name vec --version 17 --image pgvector/pgvector:pg17-trixie --port 5436 --cpu 2 --ram 4# Pick up a patch/security release for the instance's current image tag
oddk instance update app
# Switch to a different image, same major version — fast, reuses the volume
oddk instance switch app --image pgvector/pgvector:pg17-trixie
# New major version — dump/restore migration (causes downtime; backs up first)
oddk instance major-upgrade app --target-version 18 --yes
create,switch,updateandmajor-upgradepull the image automatically when needed —oddk pullis optional, for pre-warming or CI. Quiesce writes before a major upgrade; changes made after it starts are not migrated. Cross-majorswitchis rejected up front — usemajor-upgrade.
oddk parameters get # list groups
oddk parameters get --name default:2025-08-27 # inspect one
oddk parameters put custom --file params.json # create/update
oddk create --name app --version 17 --port 5432 --cpu 4 --ram 8 --parameter-group custom
oddk instance apply app --parameter-group custom # reconfigure in placeParameters support expression evaluation against the instance's resources, e.g.
"{expr}DBContainerMemoryMB / 4{/expr} MB" for shared_buffers.
Getting a group wrong does not cost you the instance. oddk instance apply
— and create, instance switch, instance update, which rebuild the
container the same way — checks what it can before the running container is
touched: a missing image, a group that will not resolve, or one whose
shared-memory arena cannot fit the instance's RAM is refused outright and the
old container keeps serving. If the new container is built but PostgreSQL never
comes up, ODDK stops it, puts the previous configuration back, and reports the
failure — the instance stays up. The stored group changes only once PostgreSQL
is accepting connections, so a failed apply can be retried with the same
group instead of forcing you to invent a different one.
The shared-memory check is deliberately one-sided: it refuses only what it can
prove will not fit (shared_buffers, WAL buffers, and the lock table implied by
max_locks_per_transaction × max_connections). Anything it cannot parse is
allowed through, because a wrong refusal would block a disaster-recovery
restore, while a config PostgreSQL rejects is already caught by the rollback
above.
parameters put takes either a bare array or the object that
oddk parameters get --name <group> --json prints, so a group round-trips
without jq. It refuses a parameter whose type is not postgres_cli_arg:
that is the only type ODDK applies, so anything else would be stored and then
silently never take effect.
oddk notify help-add --type email # print a template for a channel type
oddk notify apply --file notify.json # apply all channels from a JSON array
oddk notify test # send a test to every channel
oddk notify logs --limit 50Supported channels: Email, Slack, Telegram, Webhook. Health degraded/restored events are delivered automatically with configurable thresholds.
Channel credentials are encrypted with the master key (since 0.1.80) — the SMTP password, the Slack webhook URL, the Telegram bot token, and any headers you set on a webhook. The whole config blob is encrypted, so a credential in a webhook header is covered like any other. Configs written by earlier versions are re-encrypted the next time the daemon starts.
Two things it does not do. It does not protect a snapshot archive taken before 0.1.80 — those carry these credentials in the clear, so rotate anything that has ridden in one. And it does not protect against someone who has both
oddk.dbandmaster.key, which is what an archive plus its key amount to. Still prefer a credential you can scope and rotate (a dedicated SMTP user, a per-deployment webhook) over one that grants anything else.
An instance left in error by a failed operation — an apply whose rollback also
failed, a start that never reached readiness, a switch interrupted by a daemon
restart — is reported as broken rather than skipped, so a deployment cannot read
as healthy while an instance has no working cluster. The message names the
reason, which is what separates a stalled operation from a cluster that simply
stopped answering.
Note that oddk snapshot apply deliberately leaves configuration-only instances
in error, so a disaster-recovery restore will raise this too; the notification
says so and points at oddk checklist.
Scheduled runs notify on failure. A scheduled backup or snapshot that ends with any failed phase sends one message to every configured channel, naming what failed and why — and distinguishing "no new archive was produced" from "an archive was produced but does not hold every instance's data" and from "the archive exists but could not be uploaded or pruned", because those need different responses. A run that succeeds after a failure sends a short "Recovered" notice; a clean run after a clean run says nothing. One message per run, never one per phase.
Without a channel configured, failures are still recorded — oddk cron logs
shows every run and its per-phase outcome — but nothing will reach you until you
go looking.
- Daemon + CLI in one binary. The daemon exposes a local HTTP API on
127.0.0.1:5442; the CLI is a thin remote control that talks to it with a bearer token. - Sequential operations layer. All state-changing work runs one-at-a-time through an executor, preventing races and half-applied changes. Operations are uninterruptible by design — a dropped CLI connection never aborts an in-flight snapshot, backup, or restore.
- Docker-native. Instances are PostgreSQL containers on a dedicated bridge
network (
10.88.0.0/16), each bound to the host-local gateway10.88.0.1. - SQLite state. Instance config, the snapshot and backup catalogues, schedules, health history, and encrypted secrets live in a local SQLite database under the data dir.
- Self-healing startup. On boot the daemon reconciles stored instance state
against actual container state and sweeps orphaned temp artifacts from any
interrupted operation. An instance left mid-operation by a crash is marked
errorwith the remedy in the log, rather than being left in a transient state that health checks would skip. - Graceful shutdown. On
SIGTERM(i.e.systemctl stop oddk) the daemon stops accepting new work and waits for the operation in flight to finish — operations are uninterruptible by design, so a snapshot, restore or major upgrade is allowed to complete rather than being killed part-way. Send the signal a second time to stop waiting. The systemd unit allows 30 minutes for this; adjustTimeoutStopSecif your operations run longer.
- Encrypted secrets at rest. Postgres passwords, S3 keys and notification
channel configs are encrypted with AES-256-GCM (self-describing
3ncr.org/1format) using a 32-byte master key at{dataDir}/master.key(mode0600). Notification configs joined the list in 0.1.80; configs written earlier are re-encrypted the next time the daemon starts, and a snapshot archive taken before then still carries them in the clear. The key file is one self-describing line so it can be identified wherever it ends up:ODDK-SECRET-MASTER-KEY;V1;<base64url>;<checksum>. The checksum is the first 4 bytes of SHA-256 over the base64url text —printf %s '<payload>' | sha256sum | cut -c1-8— and exists to tell "the right key, copied badly" apart from "the wrong key". Key files written by ODDK <= 0.1.59 were a bare base64url string; they are still read and are rewritten in place on the next daemon start (the key material never changes). Because ODDK <= 0.1.59 cannot read the new format, that first start also saves the previous file asmaster.key.pre-v1— if you roll the binary back, restore it withmv master.key.pre-v1 master.key. - Secrets stay out of container metadata. Since 0.1.81 an instance's
postgres password is never written into its container's Docker config, which
Docker keeps for the container's lifetime and hands to anything that can read
container metadata. A fresh cluster is initialised with a throwaway and the
real password is set over SQL once it is ready, so
docker inspectshows a value that authenticates nothing; recreating a container (instance apply/switch/update) passes no password at all. Containers created before 0.1.81 keep their old value until their next recreate — clearing it sooner would mean restarting the database purely to tidy metadata. Note this is hygiene rather than a boundary: anyone who can read that metadata can alsodocker exec ... psql -U postgres, which the cluster answers over its local socket without a password. - Tokenized API auth. Tokens are Argon2-hashed and compared in constant time; the plaintext is shown only at creation.
- Loopback by default. The API binds
127.0.0.1.--allow-remoteexists but sends the token over cleartext HTTP — preferssh -L 5442:localhost:5442. - Host-local Postgres. Containers bind the Docker bridge gateway, not a public interface.
- Unprivileged service user. The daemon runs as the
oddkuser with no login shell.
The threat model is a single trusted operator on a single host. ODDK is not hardened for hostile multi-tenant use.
make build # build the single binary into ./bin/oddk
make test # unit tests
make test-e2e # end-to-end tests (requires Docker)
make test-all # both
make lint # golangci-lint (managed via `go tool`, no separate install)Run the daemon directly during development:
./bin/oddk daemon [--port 5442] [--data-dir ./data] [--backup-dir ./backups]The daemon does not mint a token itself. Provision a CLI config with
oddk auth mint (run as the data-dir owner — in dev that's just you, so no
sudo needed; --json prints the config instead of eval-able shell). The CLI
reads .oddk-cli.json in the current directory or ~/.config/oddk/cli.json.
Toolchain: Go 1.26+, Docker. Linux (primary) or macOS (development).
MIT — see LICENSE.