⚠️ Status: WIP — not yet on Hex. Requires OTP 27+; erlexec fails to compile on OTP 26.
Add full AWS deployment to any Elixir application — umbrella or single-app — backed by Terraform, Ansible, and S3.
DeployEx ships 68 Mix tasks, EEx templates for Terraform and Ansible, an interactive TUI wizard, smart change-detection releases, ephemeral QA nodes with optional CI-gated deploys, and a priv-template upgrade pipeline with optional LLM-assisted merging.
You write Elixir. deploy_ex handles the rest — but exposes everything as plain Mix tasks so you can drop into any layer when you need to.
git push origin mainGitHub Actions builds, uploads, and deploys via Ansible. Only apps that actually changed rebuild — change detection runs against git diff, mix.lock, and mix deps.tree. Phoenix asset pipelines (esbuild, sass, tailwind, phx.digest) run automatically when assets are detected.
Define them in mix.exs as releases. Each release becomes an EC2 instance group with its own systemd unit, optional load balancer, optional autoscaling group, and per-app log/metric streams. Umbrella apps can be split into multiple deployable services, or bundled into one — your call.
mix ansible.rollback # last release
mix ansible.rollback --select # interactive history pickerRelease history lives in S3 alongside the artifacts.
mix deploy_ex.qa.create my_app --sha abc1234 --tag canarySpins up an ephemeral EC2 instance running that exact SHA, optionally attaches it to your load balancer, and (if you opt in) fronts it with a Let's Encrypt cert on its public IP. mix deploy_ex.qa.destroy cleans everything up.
eval "$(mix deploy_ex.ssh -s my_app --iex)" # remote IEx
eval "$(mix deploy_ex.ssh -s my_app --log)" # tail journalctl
eval "$(mix deploy_ex.ssh -s my_app --root)" # sudo shellThe eval pattern means you can wrap it in shell aliases — my-app-iex from anywhere.
ssh into a node, then: /srv/<app>/bin/migrate.sh migrate
# or rollback: /srv/<app>/bin/migrate.sh rollback 20240101120000A single migrate.sh ships in every release tarball; it discovers :ecto_repos from your loaded apps automatically.
deploy_ex tags every instance with Group and InstanceGroup. Pair with libcluster_ec2_tag_strategy and your nodes find each other on boot — no static IP lists, no DNS configuration. Node.list() works.
mix deploy_ex.export_priv # render templates into ./deploys/ — now they're yours
# ... edit anything ...
mix deploy_ex.upgrade_priv # later: merge upstream changes back inThree upgrade modes — interactive per-hunk, LLM-assisted review, or fully autonomous merge. Modifications are tracked by SHA256 so unmodified files update silently.
mix deploy_exTUI wizard with search, form-based inputs, and progress streams. Auto-disables in CI / non-TTY environments.
Terraform for AWS provisioning (VPC, EC2, RDS, ALB, IAM, S3, DynamoDB state lock). Ansible for server config (BEAM tuning, log shipping, monitoring agents). All templates live in priv/ and render to ./deploys/ — fully owned by you, never overwritten by deploy_ex updates.
Optional services, toggle off with --no-* at build time: Postgres, Redis, Grafana UI, Loki, Prometheus, Sentry.
Each step is one action. Run them in order; deploys flow through GitHub Actions, not your machine.
Edit mix.exs:
def deps do
[
{:deploy_ex, "~> 0.1"}
]
endMake sure every release in mix.exs ends its steps: list with :tar.
mix deps.getmix deploy_ex.full_setup -yaWhat it does: creates the Terraform state bucket + lock table, generates ./deploys/{terraform,ansible}/, runs terraform apply to provision VPC + EC2 + RDS + S3 + IAM, then runs ansible.setup to bootstrap the instances. It does not deploy any release — that's CI's job (Step 6 onward).
Flags: -y auto-approves terraform, -a pulls AWS credentials from ~/.aws/credentials into Ansible group_vars, -p skips the wait + ansible.setup steps if you want infra-only.
Time: 10–25 minutes for the first run (mostly EC2 instance boot and Ansible bootstrap).
$EDITOR deploys/terraform/variables.tfThe <app>_project map declares per-app infrastructure: instance type, count, EBS, load balancer, autoscaling. Defaults are conservative (t3.nano, single instance, no LB) — adjust before applying real workloads. See Terraform Variables for the schema.
mix terraform.plan
mix terraform.apply -yplan previews the diff — read it. apply executes.
mix deploy_ex.install_github_actionWrites .github/workflows/deploy-ex-release.yml (build + upload + deploy on every push) and .github/workflows/setup-new-nodes.yml (every 15 min: detects instances missing the SetupComplete tag and runs ansible.setup).
mix deploy_ex.install_migration_scriptWrites rel/overlays/bin/migrate.sh — Mix copies it into every release tarball, so on the server it lives at /srv/<release>/bin/migrate.sh.
git add .github rel/overlays deploys
git commit -m "chore: deploy_ex bootstrap"You own everything in deploys/. Subsequent mix terraform.build / mix ansible.build runs are additive — they merge new app entries into your customised files without overwriting them.
In GitHub: Settings → Secrets and variables → Actions → New repository secret.
| Secret | Value |
|---|---|
DEPLOY_EX_AWS_ACCESS_KEY_ID |
AWS access key (the deploy IAM user, not your console login) |
DEPLOY_EX_AWS_SECRET_ACCESS_KEY |
matching secret key |
EC2_PEM_FILE |
full contents of deploys/terraform/*.pem. Copy with cat deploys/terraform/*.pem | pbcopy (macOS) |
For every env var your app needs at compile or runtime, add a secret prefixed __DEPLOY_EX__. Examples:
| Secret name | Becomes env var |
|---|---|
__DEPLOY_EX__DATABASE_URL |
DATABASE_URL |
__DEPLOY_EX__SECRET_KEY_BASE |
SECRET_KEY_BASE |
__DEPLOY_EX__SENTRY_DSN |
SENTRY_DSN |
The prefix is stripped automatically. Available during mix compile (so runtime config can read them) and exported on deployed instances.
In GitHub: Settings → Actions → General → Workflow permissions — select "Read and write permissions". Required for the workflow's auto-commit step (when terraform.build adds drift to deploys/).
If you have branch protection on main, the auto-commit step will fail. Either:
- Disable protection on
main(simplest, fine for solo / small teams), or - Add a PAT or GitHub App token with bypass permissions and replace
${{ secrets.GITHUB_TOKEN }}references in the workflow with your token. Document the rotation owner in your team runbook.
See Configuration → GitHub Actions Setup for full details.
git push origin mainWatch progress at https://github.com/<owner>/<repo>/actions. The workflow:
- Compiles your project with
__DEPLOY_EX__*secrets injected as env vars mix deploy_ex.ssh.authorize— whitelists the runner IPmix deploy_ex.release— builds changed apps onlymix deploy_ex.upload— pushes tarballs to S3 (auto---qaforqa/*branches)- Writes the PEM file from the secret onto the runner
mix ansible.deploy --target-sha <sha>— deploys to instancesmix deploy_ex.ssh.authorize -r— deauthorizes the runner IP
After Step 13, your day-to-day is just:
git push origin main # CI handles release → upload → deployChange detection compares git SHAs, mix.lock diffs, and mix deps.tree — only changed apps rebuild. To roll back: mix ansible.rollback or mix ansible.rollback --select for a picker.
For SSH and ops commands, see SSH (Eval Pattern) below.
- Git (required for change detection)
- Terraform / OpenTofu — auto-installed on macOS, Debian/Ubuntu, Alpine, Amazon Linux
- Ansible — auto-installed via pip3 if missing
ghCLI — required by the default CI build flow onqa.create(skipped with--use-local-build); auto-installed when invoked- AWS credentials — env vars, AWS CLI profile, or instance role
- Windows is not supported. Use WSL.
Every release in your mix.exs must end its steps: list with :tar.
The mix deploy_ex.full_setup step from the Quickstart chains:
terraform.create_state_bucket+create_state_lock_table— S3 + DynamoDB for Terraform stateterraform.build→apply→refresh— generate and apply infraansible.build→ wait →ping→setup— generate inventory + bootstrap servers
It stops there. Releases are deployed by CI (or manually with mix deploy_ex.release && mix deploy_ex.upload && mix ansible.deploy).
mix deploy_ex.release --only app1 --only app2 # build subset
mix deploy_ex.release --except app3 # exclude
mix deploy_ex.release --force # rebuild everything
mix ansible.deploy --target-sha abc1234 # specific SHA
mix ansible.deploy --target-sha auto # newest on current branch
mix ansible.rollback # previous release
mix ansible.rollback --select # interactive pickerPhoenix apps automatically run mix assets.deploy (esbuild + sass + tailwind + phx.digest) when assets are detected.
mix deploy_ex.ssh -s prints the ssh command instead of running it, so you can chain it into a shell:
eval "$(mix deploy_ex.ssh -s my_app)" # SSH directly
eval "$(mix deploy_ex.ssh -s --root my_app)" # as root
eval "$(mix deploy_ex.ssh -s --log my_app)" # tail logs
eval "$(mix deploy_ex.ssh -s --iex my_app)" # remote IExWrap it in a shell function so you can my-app-ssh app_name --log from anywhere:
# bash / zsh
alias my-app-ssh='pushd ~/path/to/project >/dev/null && mix compile --quiet && eval "$(mix deploy_ex.ssh -s $@)" && popd >/dev/null'
# fish
function my-app-ssh
pushd ~/path/to/project &&
set ssh_command (mix deploy_ex.ssh $argv -s) &&
eval $ssh_command &&
popd
endAuthorise SSH access first — by default ingress is locked down:
mix deploy_ex.ssh.authorize # add current IP
mix deploy_ex.ssh.authorize --removeFull reference: Connecting to Nodes.
Per-app infrastructure — instance type, count, EBS, load balancer, autoscaling — is declared in deploys/terraform/variables.tf. Edit the file, then mix terraform.apply. Don't manage scale or instance type via the AWS console; deploy_ex is the source of truth.
my_app_project = {
my_app = {
instance_type = "t3.small"
instance_count = 2
load_balancer = { enable = true, port = 80, instance_port = 4000 }
autoscaling = {
enable = true
min_size = 2
max_size = 10
desired_capacity = 3
cpu_target_percent = 60
ignore_capacity_changes = false # see Terraform Variables guide
}
}
}Standard workflow:
mix terraform.plan # preview changes
mix terraform.apply # apply
mix ansible.build # if instance count or apps changed
mix ansible.setup --only my_app
mix ansible.deploy --only my_appmix deploy_ex.autoscale.scale and mix deploy_ex.autoscale.refresh are runtime levers (manual override, rolling deploy) — they call AWS APIs directly and don't edit variables.tf. The ignore_capacity_changes flag controls whether those runtime overrides survive the next terraform.apply.
The full schema (templates, scheduled scaling, EBS pool, multi-Launch-Template setups) is in Terraform Variables. Autoscaling internals: Autoscaling Explanation.
Ephemeral EC2 instances for testing specific SHAs:
mix deploy_ex.qa.create my_app # prompt for SHA, CI build (default)
mix deploy_ex.qa.create my_app --sha abc1234 --tag canary --attach-lb
mix deploy_ex.qa.create my_app --public-ip-cert --tag canary # CI-gated, public-IP TLS
mix deploy_ex.qa.create my_app --use-local-build # opt out of CI build
mix deploy_ex.qa.deploy my_app --sha def5678
mix deploy_ex.qa.list
mix deploy_ex.qa.destroy my_app
mix deploy_ex.qa.cleanup --dry-run--public-ip-cert issues a Let's Encrypt cert via HTTP-01 and triggers an LLM-assisted rewrite of host config so the node serves traffic from its public IP. Originals are restored automatically by qa.destroy. Requires :llm_provider configured.
By default qa.create commits + pushes the rewrites to a QA branch, finds the matching GitHub Actions workflow (parsing on.push.branches globs and looking for jobs that run mix deploy_ex.release), patches it with a Deploy to QA Node step that runs after the build, waits up to --build-timeout minutes for the build, and prompts a 4-option recovery menu on failure (rollback / leave / destroy node only / revert + repush). Pass --use-local-build to fall back to a locally-built release.
See QA Nodes guide for the full flow.
config :deploy_ex,
aws_region: "us-west-2",
aws_resource_group: "MyApp Backend",
aws_release_bucket: "myapp-elixir-deploys-prod",
deploy_folder: "./deploys",
llm_provider: {LangChain.ChatModels.ChatAnthropic, model: "claude-sonnet-4-6"}llm_provider is required for --ai-review, --llm-merge, and --public-ip-cert. Pass an API key via LangChain config: config :langchain, anthropic_key: System.get_env("ANTHROPIC_API_KEY").
See the Configuration Reference for every key, environment variable, and the redeploy whitelist/blacklist format.
The Terraform / Ansible templates live inside the dependency. To take ownership:
mix deploy_ex.export_privThis renders every template with your project's config and writes them to ./deploys/, plus a .deploy_ex_manifest.exs recording each file's SHA256. From here, you own the files.
After upgrading the deploy_ex dep, sync upstream changes:
mix deploy_ex.upgrade_priv # interactive per-hunk DiffViewer
mix deploy_ex.upgrade_priv --ai-review # LLM proposes accept/reject per file
mix deploy_ex.upgrade_priv --llm-merge # LLM applies all changes (with backup)The upgrade pipeline uses DeployEx.ChangePlanner to detect renames, splits, and merges via Jaro distance + LLM disambiguation, so renamed-but-edited files don't get clobbered.
mix terraform.drop # destroy infrastructure
mix deploy_ex.full_drop # destroy + remove ./deploys + .github workflows + state bucketThe guides/ folder is the canonical documentation:
- Introduction
- Tutorial — Getting Started (covers multi-Phoenix-app config)
- How-to
- Deploying Releases
- QA Nodes — including CI build flow /
--use-local-build, public-IP TLS, QA tag schema - Connecting to Nodes — SSH, eval pattern, alias recipes
- Managing Infrastructure — terraform, priv upgrades, EBS, teardown
- Autoscaling — scale, refresh, deployment strategies
- Database Operations
- Load Testing — k6 runners + Prometheus remote-write
- Monitoring — Grafana, Loki, Prometheus, Sentry, dashboards
- Clustering — libcluster + EC2Tag strategy
- Troubleshooting — Ansible, SSH, autoscaling, RDS upgrades, monitoring, tags
- Reference
- Mix Tasks — every task with switches
- Configuration — every config key, universal options, IaC switching, GitHub secrets
- Terraform Variables — per-app infrastructure schema
- Codebase Summary — module inventory
- Testing
- Explanation
- System Architecture — diagrams of every pipeline
- Autoscaling Internals — instance lifecycle, version consistency, IAM, deployment strategies
- Code Standards
Or run mix deploy_ex to launch the interactive TUI wizard for live discovery.
Tests:
mix test # all
mix test test/deploy_ex/foo_test.exs:42No mocks — dependency injection via parameters. See Testing Guide and Code Standards before submitting changes.
- Deploy rollbacks
- S3-backed Terraform state
- Subnet AZ dispersal in networking layer
- OpenTofu support via
:iac_tool - Canary deploys
- Automated IP whitelist removal lambda (paired with
mix deploy_ex.ssh.authorize) - Sentry integration (currently WIP)
- Vault integration
- Static way to set up Redis from apps
- Auto-run
ansible.setupon nodes created via GitHub Actions
Big thanks to @alevan for figuring out the Ansible side of things and providing the foundation for everything in priv/ansible/. This project wouldn't exist without his help.
Also leans on libcluster_ec2_tag_strategy for cluster discovery — see Clustering.
See LICENSE (if present).