Xamal

Hex.pm Hexdocs License

Xamal is an Elixir port of Kamal — Basecamp's tool for deploying web apps anywhere. It uses Mix tasks, Elixir configuration (config/xamal.exs), native releases, and Caddy instead of Docker containers and kamal-proxy.

If you're familiar with Kamal, you should feel right at home. The operational model, hook system, secrets management, and destination-based multi-environment workflow carry over.

What's different from Kamal

Docker-specific configuration (image, registry, Dockerfile, build args, etc.) is intentionally omitted since releases replace containers entirely.

Requirements

Locally, where you run the Mix tasks:

On each target server:

Install

Add Xamal as a Mix dependency in the application you deploy:

# mix.exs
defp deps do
[
{:xamal, "~> 0.6", only: [:dev, :test], runtime: false}
]
end

Then fetch dependencies:

mix deps.get

Documentation is available on HexDocs.

Upgrading

Upgrading from 0.2.0? The escript binary, the xamal <cmd> interface, and config/deploy.yml were all replaced in 0.3.0. See UPGRADING.md for step-by-step migration instructions.

Quick start

# Generate config stubs, sample hooks, release config, and helper aliases
mix xamal.init
# Edit config/xamal.exs and .xamal/secrets, then:
mix xamal.setup

Xamal ships built-in reference docs for every config section. Run mix xamal.docs to list the available topics, or mix xamal.docs <topic> (e.g. mix xamal.docs servers) for details.

Configuration

Xamal reads Elixir config from config/xamal.exs:

import Config
config :xamal,
service: "my-app",
servers: [
web: ["192.168.0.1", "192.168.0.2"],
worker: [
hosts: ["192.168.0.3"],
cmd: ~s(bin/my_app eval "Worker.start()")
]
],
ssh: [
user: "deploy"
],
caddy: [
host: "app.example.com",
app_port: 4000
],
env: [
clear: [
PHX_HOST: "app.example.com"
],
secret: ["SECRET_KEY_BASE"]
],
release: [
name: "my_app",
mix_env: "prod"
],
health_check: [
path: "/health"
]

Important: The release.name must match a named release in your mix.exs. Xamal runs mix release <name>, which requires an explicit release definition:

# mix.exs
def project do
[
...
releases: [
my_app: [
version: {:from_app, :my_app}
]
]
]
end

Without this, mix release my_app will fail with Unknown release :my_app.

Because this is Elixir config, normal Elixir expressions such as System.get_env/1 are available. Use regular Mix aliases in your application's mix.exs for command shortcuts.

Run mix xamal.docs <topic> for detailed reference on any config section.

Builders

Releases are built locally by default. Two other modes exist for when the build environment has to match the servers — ERTS, NIFs, and port drivers are compiled into the release tarball, so they are linked against the build machine's OpenSSL and glibc and built for its CPU architecture.

config :xamal,
builder: [docker: true] # build in a Linux container
# builder: [docker: "hexpm/elixir:1.18.3-erlang-27.2.3-debian-bookworm-20250113"]
# builder: [remote: "build@build-server"] # build on another host over SSH

Docker runs the build in a Linux container. Pin the image to match your server's distro and OpenSSL. No --platform flag is passed, so the container inherits your host's architecture — an arm64 laptop produces arm64 binaries. The container bind-mounts your working directory and writes into the local _build, so a stale native _build can leak in.

Remote builds on another host over SSH. Source is synced with git archive HEAD, so only committed code is built — uncommitted changes are not sent. The build host needs Elixir and OTP installed at versions matching the targets, on the PATH for non-interactive SSH sessions (~/.bashrc is not sourced for those). The finished tarball is copied back locally, then uploaded to the servers like any other build.

Use remote mode when the build environment must match production exactly, or when Docker cannot produce binaries for the target OS at all — FreeBSD, for instance. Pointing remote at a deploy host is the strongest guarantee that NIFs and OpenSSL match.

Commands

Run mix help | grep xamal to list every available task.

Deploy

mix xamal.setup # Bootstrap servers and deploy
mix xamal.deploy # Build, distribute, and boot
mix xamal.redeploy # Deploy without bootstrapping
mix xamal.rollback VERSION # Roll back to a previous version
mix xamal.prune # Remove old releases, keeping the retained count
mix xamal.remove # Remove remote release and proxy resources

App

mix xamal.app.boot # Zero-downtime restart
mix xamal.app.start # Start the service on its active port (no swap)
mix xamal.app.stop # Stop application services
mix xamal.app.exec CMD # Run a command in the release context
mix xamal.app.logs -f # Tail application logs
mix xamal.app.version # Show the current deployed version per host
mix xamal.app.stale_releases # Preview releases that pruning would remove
mix xamal.app.maintenance # Enable maintenance mode (503)
mix xamal.app.live # Disable maintenance mode
mix xamal.iex # Open a remote IEx session
mix xamal.migrate # Run the release migrator (<App>.Release.migrate)

Inspect

mix xamal.versions # List release versions on servers
mix xamal.details # Show app and proxy status
mix xamal.audit # Show the audit log
mix xamal.version # Print the installed Xamal version

Build, server, and lock

mix xamal.build # Build release tarball
mix xamal.build.upload # Upload release tarball to servers
mix xamal.build.deliver # Build and upload release
mix xamal.build.details # Print build configuration
mix xamal.server.bootstrap # Bootstrap target servers
mix xamal.server.exec CMD # Run a shell command on servers
mix xamal.server.logs # Show Caddy/proxy logs from servers
mix xamal.lock.status # Check deploy lock
mix xamal.lock.acquire # Acquire deploy lock
mix xamal.lock.release # Release deploy lock

Config, docs, and secrets

mix xamal.config # Show merged configuration
mix xamal.docs hooks # Show hook documentation
mix xamal.secrets.print # Show secrets (redacted)
mix xamal.secrets.extract KEY # Print one secret value
mix xamal.secrets.fetch ADAPTER [OPTIONS]

Hooks

Shell scripts in .xamal/hooks/ that run locally at lifecycle points:

Hook When
pre-build Before building the release
post-build After building the release
pre-deploy Before deploying
post-deploy After deploying
pre-app-boot Before booting the app
post-app-boot After booting the app
pre-caddy-reload Before Caddy config reload
post-caddy-reload After Caddy config reload

Hooks receive environment variables like XAMAL_SERVICE, XAMAL_VERSION, XAMAL_HOSTS, XAMAL_PERFORMER, etc. Run mix xamal.docs hooks for the full list.

Destinations

Multi-environment deploys work the same as Kamal:

mix xamal.deploy -d staging
mix xamal.deploy -d production

With override files like config/xamal/staging.exs and secrets in .xamal/secrets.staging.

Multiple apps on one host

Several apps can share a server and its Caddy. Each app gets its own directory, systemd unit, and Caddyfile, and /etc/caddy/Caddyfile imports them all. Give each app a distinct:

# app one # app two
caddy: [host: "one.com", app_port: 4000] caddy: [host: "two.com", app_port: 4010]

mix xamal.server.bootstrap refuses to install an app whose release name, ports, or catch-all site would collide with an app already on the host. Bootstrap only adds the import line to /etc/caddy/Caddyfile if it is missing, so sites you manage there yourself are kept.

Destinations of the same service share its directory, so to run staging and production on one host, give each destination its own service name.

License

MIT