Deploy Phoenix applications to FreeBSD servers using Jails.
Alcaide provides a Kamal-like workflow for the Phoenix + FreeBSD ecosystem: one configuration file and a single command to deploy your app with blue/green rotation and zero downtime.
alcaide deploy
- Simplicity over generality. Optimized for the Phoenix + FreeBSD case. Not a general-purpose orchestrator.
- No remote agent. Everything runs via SSH from your local machine. The server needs nothing beyond base FreeBSD.
- Blue/green from the start. Two jails alternate on each deploy. The switch is atomic from the proxy's perspective.
- Zero runtime dependencies. Only Erlang/OTP modules:
:ssh,:crypto,:public_key. - Fail loudly. Any failed step stops the process and reports the error clearly.
Add Alcaide to your Phoenix project as a dev dependency:
# mix.exs
defp deps do
[
{:alcaide, github: "jhondta/alcaide", only: :dev}
]
endThen fetch the dependency:
mix deps.getCopy the example and edit it for your project:
cp deps/alcaide/deploy.exs.example deploy.exsAt minimum, set your app name, server host, and domain:
import Config
config :alcaide,
app: :my_app,
server: [host: "192.168.1.100", user: "deploy"],
domain: "myapp.example.com",
app_jail: [
base_path: "/jails",
freebsd_version: "15.0-RELEASE",
port: 4000
],
env: [
PHX_HOST: "myapp.example.com",
PORT: "4000"
]mix alcaide.secrets.init
mix alcaide.secrets.editThis creates an encrypted deploy.secrets.exs (safe to commit) and a master key at .alcaide/master.key (add to .gitignore).
mix alcaide.setupThis connects via SSH and configures the FreeBSD server: enables jails and Linux binary compatibility, downloads the base system template, installs Caddy, provisions any configured accessories (database, etc.), and sets up the build jail with Elixir, Erlang, Node.js, and git for compiling releases natively on FreeBSD.
mix alcaide.deployUploads your source code to the build jail on the server, compiles the release natively on FreeBSD, creates an app jail, runs migrations, checks health, and switches the proxy. Done.
All commands accept -c PATH to specify an alternate config file (default: deploy.exs).
Prepares the server for deployments. Run once per server (or when re-provisioning).
mix alcaide.setupWhat it does:
- Verifies the server is running FreeBSD and detects architecture.
- Enables the jail subsystem and configures the
lo1loopback interface. - Enables Linux binary compatibility (linuxulator) for asset tool support.
- Configures NAT via PF so jails can reach the internet.
- Downloads and extracts the FreeBSD base system template.
- Installs and configures Caddy as a reverse proxy.
- Provisions accessory jails (PostgreSQL, etc.) if configured.
- Provisions the build jail with Elixir, Erlang, Node.js, and git.
Deploys a new version with blue/green rotation.
mix alcaide.deployThe deploy pipeline:
- Destroy any stale jail left from a previous deploy cycle.
- Ensure the build jail is running.
- Create a source tarball via
git archiveand upload it to the build jail. - Build the release inside the build jail (
mix deps.get,mix assets.deploy,mix release). - Determine the next slot (blue or green).
- Load encrypted secrets (if configured).
- Create a new app jail by cloning the base template.
- Extract the release into the app jail (local copy on the server, no network transfer).
- Start the jail and the application.
- Run Ecto migrations.
- Health check — verify the app responds on its internal IP.
- Update the Caddy configuration to point to the new jail.
- Stop the previous jail (preserved for rollback).
The release is compiled natively on FreeBSD and includes ERTS, so NIFs and all dependencies work correctly.
Reverts to the previous deployment by reactivating the stopped jail.
mix alcaide.rollbackThe previous jail is preserved after each deploy. Rollback starts it, verifies health, switches the proxy back, and stops the current jail. If the previous jail has already been destroyed (by a subsequent deploy), a new deploy is needed instead.
Shows application logs from the active jail.
# View the last 100 lines (default)
mix alcaide.logs
# Follow logs in real time
mix alcaide.logs -f
# Show the last N lines
mix alcaide.logs -n 50
# Combine options
mix alcaide.logs -f -n 200Executes a command inside the active jail.
# Open a remote console
mix alcaide.run "bin/my_app remote"
# Run an Elixir expression
mix alcaide.run "bin/my_app eval 'IO.inspect(MyApp.Repo.aggregate(MyApp.User, :count))'"
# Run a shell command
mix alcaide.run "uname -a"Generates a master key and creates an encrypted secrets file.
mix alcaide.secrets.initCreates:
.alcaide/master.key— the encryption key (add to.gitignore, never commit)deploy.secrets.exs— encrypted file (safe to commit)
Decrypts the secrets file, opens it in your editor, and re-encrypts on save.
mix alcaide.secrets.editUses $EDITOR, falling back to $VISUAL, then vi.
The secrets file uses the same format as deploy.exs:
import Config
config :alcaide,
env: [
SECRET_KEY_BASE: "your-secret-key-base-here",
DATABASE_URL: "ecto://user:pass@10.0.0.4/my_app_prod"
]Secret env vars override values with the same key in deploy.exs.
The deploy.exs file uses Elixir's Config module. All options are under the :alcaide key.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
app |
atom | yes | — | Application name, must match your mix.exs :app |
server |
keyword | yes | — | SSH connection settings |
server.host |
string | yes | — | Server hostname or IP address |
server.user |
string | no | "root" |
SSH user |
server.port |
integer | no | 22 |
SSH port |
domain |
string | no | nil |
Public domain for Caddy TLS. If omitted, serves HTTP on port 80 |
app_jail |
keyword | yes | — | Jail configuration |
app_jail.base_path |
string | yes | — | Directory on the server where jails are stored |
app_jail.freebsd_version |
string | yes | — | FreeBSD version for the base template (e.g., "15.0-RELEASE") |
app_jail.port |
integer | no | 4000 |
Internal port the Phoenix app listens on |
accessories |
keyword | no | [] |
Auxiliary services, each in its own jail |
env |
keyword | no | [] |
Environment variables injected into the app jail |
Each accessory runs in a persistent jail. Currently supported: :postgresql.
accessories: [
db: [
type: :postgresql,
version: "18",
volume: "/data/postgres:/var/db/postgresql",
port: 5432
]
]| Key | Type | Required | Description |
|---|---|---|---|
type |
atom | yes | Service type (:postgresql) |
version |
string | yes | Package version to install |
volume |
string | yes | host_path:jail_path — data persists on the host via nullfs mount |
port |
integer | no | Service port (default: 5432) |
user |
string | no | Database user created during setup (default: "app") |
password |
string | no | Password for the database user (default: "app") |
database |
string | no | Database name created during setup (default: "{app}_prod") |
The database jail gets IP 10.0.0.4 on the lo1 interface. The user, password, and database fields are used during alcaide setup to create the PostgreSQL role and database. The DATABASE_URL in deploy.secrets.exs is what the Phoenix app uses at runtime — both should use matching credentials:
# In deploy.secrets.exs
DATABASE_URL: "ecto://my_app:strong_password@10.0.0.4/my_app_prod"Application jails are named {app}_blue and {app}_green. Each deploy creates the opposite slot and switches traffic to it.
First deploy:
my_app_blue ← created, receives traffic
Second deploy:
my_app_blue ← was active, now stopped (kept for rollback)
my_app_green ← created, receives traffic
Third deploy:
my_app_blue ← stale jail destroyed at start
my_app_green ← was active, now stopped (kept for rollback)
my_app_blue ← created again, receives traffic
The previous jail is stopped (not destroyed) after each deploy, enabling rollback. It is destroyed at the start of the next deploy cycle.
Each jail gets a fixed IP on the host's lo1 loopback interface:
Host: 10.0.0.1 (lo1)
my_app_blue: 10.0.0.2:4000
my_app_green: 10.0.0.3:4000
db (postgres): 10.0.0.4:5432
my_app_build: 10.0.0.5 (build jail, no exposed port)
Caddy runs on the host and reverse-proxies to the active jail's internal IP. When a domain is configured, Caddy automatically provisions TLS certificates via Let's Encrypt. Without a domain, it serves HTTP on port 80.
On each deploy, Alcaide updates the Caddyfile and reloads Caddy without interrupting active connections.
Migrations run inside the new jail before switching the proxy:
jexec my_app_green bin/my_app eval "MyApp.Release.migrate()"This requires the standard MyApp.Release.migrate/0 function from the Phoenix deployment guide. Migrations must be backwards-compatible with the previous code version, since the old jail continues serving traffic until the proxy switches.
Alcaide builds releases natively on FreeBSD. Some asset tools (notably Tailwind CSS v4+) ship standalone binaries that don't include a FreeBSD build. Alcaide handles this transparently using FreeBSD's Linux binary compatibility layer (linuxulator):
- During
alcaide setup, the linuxulator is enabled on the host and the build jail is configured with the required Linux filesystem mounts. - Before
mix assets.deploy, Alcaide detects the configured Tailwind version and downloads the Linux binary, placing it where the Elixir wrapper expects the FreeBSD binary. - The linuxulator executes the Linux binary transparently — no changes needed in the Phoenix app.
This means mix assets.deploy works inside the build jail exactly as it does on Linux or macOS. No npm workarounds or config overrides required.
Secrets are encrypted with AES-256-GCM using a master key stored locally. The encrypted file can be committed to version control. During deployment, secrets are decrypted and merged with the env vars from deploy.exs — secret values override base values with the same key.
All server communication uses Erlang/OTP's :ssh and :ssh_connection modules directly. No external SSH client or library is required — file uploads use SSH channel pipes instead of scp. Alcaide uses your default SSH key (~/.ssh/id_rsa, ~/.ssh/id_ed25519, etc.).
- Local machine: Elixir 1.17+, Erlang/OTP 26+
- Server: FreeBSD 14.0+ with SSH access
- Phoenix app: Standard
mix releasesetup withMIX_ENV=prod
lib/alcaide/
├── cli.ex Entry point and argument parsing
├── config.ex Loading and validation of deploy.exs
├── ssh.ex SSH connection, command execution, file upload
├── build_jail.ex Persistent build jail (compile releases on FreeBSD)
├── jail.ex App jail lifecycle and blue/green rotation
├── proxy.ex Caddy configuration and reload
├── migrations.ex Ecto migration execution
├── secrets.ex AES-256-GCM encryption for secrets
├── accessories.ex Auxiliary jail management (PostgreSQL)
├── health_check.ex HTTP health checks
├── shell.ex Shell argument escaping
├── output.ex Terminal output formatting
└── pipeline/
├── pipeline.ex Step runner with rollback support
├── step.ex Step behaviour definition
└── steps/ Individual deploy pipeline steps
MIT