WarmblyDocs

Install

One command installs Warmbly from published release images, with a wizard that asks where your data lives, what is kept and for how long, and how it is backed up.

curl -fsSL https://warmbly.com/install.sh | sh

No clone, no compiler, no toolchain. The script checks Docker, creates one directory, generates every secret, pulls the pinned release images, starts the stack and prints the link that claims your instance. On a clean box with Docker already installed it finishes in under two minutes.

Add --wizard and it asks the questions a config reference cannot ask for you.

curl -fsSL https://warmbly.com/install.sh | sh -s -- --wizard

What it installs

Where/opt/warmbly, or wherever you point --dir
Whatdocker-compose.yml, a .env at 0600, keys-backup.txt, and the data root
VersionThe newest release, resolved once and pinned in .env. Never latest
Imagesghcr.io/warmbly/warmbly/*, multi-arch for amd64 and arm64
ServicesBackend, consumer, worker, dashboard, admin, Postgres, Redis, NATS, and (unless you say core only) tracking, realtime and forms

Nothing is built on your machine. The clone-and-build path still exists and is still supported; see self-hosting for when you want it.

Before you run it

Piping a script into a shell is a fair thing to be uneasy about, and the objection is only answerable if the script is versioned, inspectable and checksummed. All three are:

curl -fsSLO https://warmbly.com/install.sh
curl -fsSLO https://warmbly.com/install.sh.sha256
sha256sum -c install.sh.sha256
less install.sh
sh install.sh

The script itself is written to deserve that: set -eu, every line inside a function, and main "$@" on the very last line, so a download that is cut off half way executes nothing at all. It never asks to be piped into sudo; it asks for elevation only for the specific steps that need it, and names them.

You also do not have to run it to see what it would do:

sh install.sh --dry-run

That prints the exact .env and compose file it would write, and touches nothing.

And after it runs

Every release publishes an images.json asset listing the manifest digest of each image in it. Once the pull finishes the installer compares what actually landed on the machine against that file, and stops before starting anything if they differ. You can check the same thing yourself at any time:

docker image inspect ghcr.io/warmbly/warmbly/backend:v1.4.2 --format '{{index .RepoDigests 0}}'

An install pointed at your own registry with --registry has no manifest to check against, and the installer says so rather than pretending it verified something.

Requirements

You needNotes
Linux or macOS, amd64 or arm64On Windows, run it inside WSL
Docker 20.10+ with Compose v2The script offers to install Docker on Linux, and never does so quietly
~2 GB diskThe images, not a build cache. There is no build
4 GB RAMThe running stack idles near 300 MB
Free ports8080, 5173, 5174, and 3000, 4000, 8090 with every component

Port 3000 is the one that actually collides: it is the default for a lot of other self-hosted software. The installer checks every port before it writes anything and tells you which one is taken.

The wizard

--wizard walks nine steps. Every answer has a default that matches the fast path, so pressing enter through the whole thing produces exactly the install the one-liner produces.

Every menu takes the keys you already use:

KeysDoes
, j k, ctrl-n ctrl-pMove
ctrl-d ctrl-uHalf a screen
gg, GFirst, last
1 to 9Jump straight to that option
enter, space, lTake it
q, esc, ctrl-cLeave without installing

The review screen at the end takes enter to install, e to go back into a section, and q to quit.

Steps append rather than replacing each other, so the whole run stays scrollable and nothing above the installer is touched. --clear redraws the screen at each step instead, if you prefer that.

Where it lives

One directory holds the compose file, the .env, and, unless you move it in step three, every store this instance writes to. Default /opt/warmbly.

How it is reached

The hostname every link is built from: the dashboard URL, the unsubscribe link in campaign mail, the tracking pixel. Then how it is served:

ChoiceWhat it does
Plain HTTPPorts published as they are. Correct for localhost or a private network
Bundled CaddyAdds a Caddy service with automatic HTTPS, one hostname per surface, and binds everything else to loopback
Behind your proxyBinds every service to 127.0.0.1 and sets TRUSTED_PROXIES, which is silently wrong on every proxied install that was configured by hand

TRUSTED_PROXIES matters more than it looks. Without it every request appears to come from the proxy, so per-IP rate limits and click deduplication stop distinguishing between people.

The bundled Caddy option serves six hostnames under the domain you give it, and the installer prints the DNS records at the end:

app.example.com     admin.example.com    api.example.com
ws.example.com      track.example.com    forms.example.com

Where the data sits

The question the rest of the install cannot answer for you, and the reason the wizard exists.

ChoiceWhat you get
Under the install directory/opt/warmbly/data holds Postgres, blobs, NATS and worker state
A path you chooseAn external disk or a mount you already snapshot
Docker named volumesManaged by Docker, portable only through docker volume

A real path is the default, because it is the choice that makes backup and migration obvious: with the stack stopped, moving the instance to another host is rsync of that path plus the .env. Do not copy it while Postgres is running; a live data directory is not a consistent snapshot. To move an instance without stopping it, use warmblyctl backup. Compose reads a volume source starting with / as a bind mount and anything else as a named volume, so this is one variable per store and no second compose file.

Then, behind one prompt: an external Postgres or Redis (the bundled container is left out entirely when you bring your own), and where message bodies, attachments and avatars go.

Filesystem blobs are local to one machine

A worker running on another host writes to its own disk. Once you scale workers out, blob storage has to be S3-compatible or bodies go missing. The wizard offers MinIO, R2, B2 and AWS.

Keys and secrets

Five secrets are generated: AUTH_SECRET, INTERNAL_API_TOKEN, SECRET_KEY_BASE, KMS_LOCAL_MASTER_KEY and CREDENTIALS_ENCRYPTION_KEY. They are written 0600, APP_ENV is set to prod, and no published default is ever reachable through this path.

The last two are unrecoverable. Every mailbox credential and every per-organization key is sealed with them, so a database backup that does not travel with them restores an instance whose mailboxes authenticate against nothing. The wizard prints both, writes them to keys-backup.txt next to the install, and waits until you type that you have a copy somewhere else.

What is kept, and for how long

Two groups, both editable afterwards in Instance > Instance settings:

SettingDefaultWhat it governs
Import window90 daysHow far back the initial import reaches when a mailbox is connected
Import cap5,000The most messages that import stores per mailbox
Daily per mailbox2,000New mail one mailbox may store per UTC day
Daily per organization25,000New plus imported mail across one workspace per UTC day
Opens and clicks365 daysPer-event logs, with the client, device and approximate location of each
Form funnel events180 daysViews, starts, field-level drop-off
Audit log90 daysWho did what, from which IP address and user agent

The last three are also how long that personal data is held. A "minimal retention" preset sets every event window to 30 days.

Backups

A scheduled warmblyctl backup: a target directory, a frequency, how many to keep, and whether the encryption keys travel in the bundle. It writes backup.sh next to the install and, on a systemd host, a service and a timer.

Including the keys makes the bundle as sensitive as the instance itself. Leaving them out means keeping keys-backup.txt somewhere else, or the bundles cannot be restored at all.

Who gets in

The first owner, either as a printed claim link or unattended from WARMBLY_BOOTSTRAP_EMAIL plus a hash from warmblyctl hash-password. Then the registration posture, and platform mail: an SMTP relay, or skip it and have password resets and login codes printed to the backend log.

Footprint

Everything, or core only (no tracking pixel, no websockets, no hosted forms). Ports are checked for collisions before anything is written. Last, the update check, which is one outbound call to the GitHub releases API and the only outbound call this instance makes on its own. There is no telemetry in Warmbly.

Review

Every answer on one screen. Enter installs, e goes back to any section, q leaves without writing anything.

See it first

curl -fsSL https://warmbly.com/install.sh | sh -s -- --demo

The real wizard and the real review, with the pull, the container creation and the health wait played rather than run. It writes no file, pulls no image, creates no container and needs no Docker, so it answers "what does this actually do" without a server to try it on.

It takes about half a minute, paced so the phases are readable; a real install is several minutes, most of it the pull and the first boot's migrations. WARMBLY_DEMO_FAST=1 collapses it to under ten seconds. From a checkout: make installer-demo.

For agents

An agent should never drive the wizard: it reads raw keypresses and needs a terminal. Every answer is also a flag, so --yes with flags is silent, deterministic and idempotent, and --print-env and --dry-run show what a run would produce without producing it.

The repository ships a warmbly-install skill under skills/ covering exactly that, plus backups and moving an instance. Install it the way your agent expects, for example cp -r skills/warmbly-install ~/.claude/skills/.

Unattended

Every answer is a flag and a WARMBLY_* environment variable, so the same script provisions from Ansible or cloud-init:

curl -fsSL https://warmbly.com/install.sh | sh -s -- --yes \
  --dir /opt/warmbly \
  --host warmbly.acme.com \
  --tls caddy \
  --data-root /mnt/data/warmbly \
  --blobs s3 \
  --retention-preset minimal \
  --backup-dir /mnt/backups/warmbly \
  --version v1.4.2
FlagVariableDefault
--dirWARMBLY_DIR/opt/warmbly
--hostWARMBLY_HOSTlocalhost
--tlsWARMBLY_TLSnone (caddy, proxy)
--data-rootWARMBLY_DATA_ROOT<dir>/data (or volumes)
--blobsWARMBLY_BLOBSfilesystem (s3)
--database-urlWARMBLY_DATABASE_URLbundled Postgres
--redis-urlWARMBLY_REDIS_URLbundled Redis
--componentsWARMBLY_COMPONENTSfull (core)
--versionWARMBLY_VERSIONnewest release
--channelWARMBLY_CHANNELstable (dev)
--registryWARMBLY_IMAGE_PREFIXghcr.io/warmbly/warmbly
--backup-dirWARMBLY_BACKUP_DIRnone
--backup-keepWARMBLY_BACKUP_KEEP14
--retention-presetdefault (minimal)
--no-update-checkWARMBLY_UPDATE_CHECKon

--print-env emits the .env and exits. --dry-run prints every file it would write and exits. --demo walks the whole thing and installs nothing.

Re-running it

The installer is idempotent. A second run:

  • adopts the existing .env and never regenerates a secret, because a new AUTH_SECRET signs everyone out and a new CREDENTIALS_ENCRYPTION_KEY is permanent data loss
  • keeps the existing data root, because pointing an install at a new path is not a move, it is an empty instance next to a full one
  • keeps any keys you added to .env yourself, in a clearly marked block at the end
  • regenerates docker-compose.yml, keeping the previous one as docker-compose.yml.bak
  • refuses a directory it did not create, unless you pass --force

Put your own compose changes in a docker-compose.override.yml, which compose merges on top and the installer never touches.

Updating

The version pill in the admin panel, then Update and restart. On an install created this way the updater runs in image mode: it writes the new tag into .env, pulls, and recreates the containers whose image changed. Nothing is built. See updates.

By hand, from the install directory:

docker compose -p warmbly pull && docker compose -p warmbly up -d

Removing it

sh install.sh --uninstall

Stops and removes the containers and leaves every byte of data where it is. Adding --purge-data deletes the data root and the install directory as well, and asks you to type a phrase first, because that also destroys the only keys that could have opened a backup of it.

When it goes wrong

SymptomCause
Docker is installed but not answeringThe daemon is not running, or your user is not in the docker group yet. sudo systemctl start docker, then log out and back in
Something already listens on 3000Another self-hosted tool. Change the published port in docker-compose.yml before starting
Could not pull the release imagesThe tag does not exist, or this host cannot reach ghcr.io. Check releases
The API never answersThe first boot applies every migration. docker compose -p warmbly logs -f backend
No claim link printedThe database already has accounts, so there is nothing to claim. See first run

More in troubleshooting and instance health.

Next

  • First run: claiming the instance, the first owner, demo data
  • Data control: where every store lives, what each retention window governs, backups, and moving an instance
  • Configuration: every environment variable and every database-backed setting
  • Self-hosting: the full guide, including building from source

On this page