An opinionated, production-ready Phoenix/Ecto starter for Elixir services — PostgreSQL via Ecto, a context-seam behaviour, a Kubernetes Helm chart with BEAM tuning and autoscaling, and AI dev tooling pre-wired.
Use this template: click "Use this template" on GitHub to create your own repo, then follow SETUP.md to rename the OTP app and point the chart at your registry.
mix setup # deps.get + ecto.create + ecto.migrate + asset build
mix phx.server # http://localhost:4647
mix test # full test suite (creates + migrates the test DB automatically)The server binds to http://localhost:4647 (port is set in config/dev.exs and in
charts/__APP_NAME__-app/values.yaml).
The template ships with a minimal CRM domain: Crm.Account and Crm.Contact. These exist purely to demonstrate the patterns — replace them with your own domain once you clone the template.
ElixirServiceTemplate.Crm.Backend is an Elixir @behaviour with callbacks for accounts and contacts. The Crm context delegates to whichever implementation is configured:
# config/test.exs
config :elixir_service_template, :backend, ElixirServiceTemplate.Crm.Backend.EctoTwo implementations ship with the template:
| Module | When used |
|---|---|
Crm.Backend.Ecto |
Default (dev, prod, and test) — queries PostgreSQL via Ecto |
Crm.Backend.Fake |
Isolated unit tests — swap in via start_supervised!; no DB required |
CrmBackendContract in test/support/ defines a shared contract test. Any new
implementation can use CrmBackendContract and get the same suite for free — one
contract, multiple verified implementations.
Ecto is the idiomatic Elixir data layer. Using it here rather than a thin raw-SQL wrapper gives the service:
- Changesets — explicit validation and casting at the boundary, composable across controller / context layers.
- Typed schema structs — plain Elixir structs make pattern-matching and test setup straightforward.
- Composable queries —
import Ecto.Querylets you build queries incrementally; the compiler catches invalid field references at compile time. - Raw-SQL escape hatch — when a query outgrows the DSL,
Repo.query!/2accepts parameterized SQL directly. Values are bound parameters, never interpolated, so queries are injection-safe and plans can be cached.
The explicit context boundary means the rest of the app calls Crm.get_account/1, not
Repo.get(AccountSchema, id) directly. Data-layer details stay inside the boundary.
ElixirServiceTemplate.Release.migrate/0 runs Ecto migrations without mix. In a
production image mix is not present; instead the release entry point is used:
bin/elixir_service_template eval "ElixirServiceTemplate.Release.migrate()"The Helm chart's init container calls this command on every deploy, so the DB schema is
always up to date before traffic reaches the new pods. No manual migration steps, no
mix in the container image.
Elixir on the BEAM gives you lightweight processes, preemptive scheduling, and built-in distribution — all critical for services that need to scale horizontally without a separate WebSocket broker. Phoenix PubSub and Presence work natively across nodes with no external dependency beyond Postgres.
The BEAM also makes graceful shutdown straightforward: SIGTERM flows to a supervisor
tree that can drain in-flight requests, close DB connections, and deregister from the
load balancer in a controlled order.
The chart at charts/__APP_NAME__-app/ is a complete deployment package:
- Deployment — one container with BEAM tuning env vars and a preStop sleep hook.
- Init container — runs
Release.migrate()before traffic is accepted. - Service — ClusterIP on
app.port(default 4647). - Ingress — optional; set
ingress.enabled: trueand configureingress.base/ingress.host. - HPA — CPU + memory metrics with anti-flap
behaviorblock (see Autoscaling below). - PodDisruptionBudget — disabled by default; enable in prod overlays.
- TopologySpreadConstraints — distributes replicas across nodes to reduce blast radius.
Secrets (SECRET_KEY_BASE, DATABASE_URL) are injected via existingSecret — create
the Secret out-of-band and reference it:
# values.production.yaml
existingSecret: my-app-secretskubectl create secret generic my-app-secrets \
--from-literal=SECRET_KEY_BASE=$(openssl rand -hex 64) \
--from-literal=DATABASE_URL=ecto://user:pass@host/dbWhen beam.tuneSchedulers: true, the chart reads app.resources.limits.cpu via the
downward API and sets +S <n>:<n> explicitly — pinning the BEAM scheduler count to the
container's cgroup CPU quota.
Why explicit, not auto-detect: OTP's automatic scheduler detection reads the host's total core count at boot. In a Kubernetes pod that count can be 32+ even when the pod's CPU limit is 1 core. The VM then spawns 32 schedulers that are immediately throttled by CFS, causing schedulers to spin against the quota and adding latency. Explicit pinning avoids this.
Pinning engages only when app.resources.limits.cpu is set. Recommended: use
Guaranteed QoS — requests == limits for both CPU and memory — so the cgroup quota is
stable across rescheduling events.
The default dirty-IO scheduler count is 10, which is quota-unaware. +SDio 4 caps it
to a container-appropriate number and frees quota for application schedulers.
Enables epoll-based I/O multiplexing. Reduces system call overhead under high connection counts.
+sbwt none disables scheduler busy-waiting. On a CPU-throttled container, busy-waiting
burns the cgroup quota before real work arrives, causing latency spikes. This flag also
makes CPU-based HPA metrics accurate — without it, idle schedulers consume CPU quota,
inflating utilisation and triggering premature scale-out.
Do not set +sbt (scheduler binding) in containers. There is no stable CPU topology
under CFS throttling; binding hurts performance.
Setting beam.memoryOptimized: true adds +MBas aobf +MHas aobf +MBacul 0 +MHacul 0,
which releases memory carriers back to the OS sooner. This reduces RSS at the cost of
slightly more frequent mmap calls. Test per workload before enabling in production.
Appended verbatim after the built-in flags. Suggested values:
+swt very_low— faster idle-scheduler wakeup, lower tail latency.+P 2000000 +Q 1000000— raise process/port limits for very high connection counts.
Three settings work together:
app.terminationGracePeriodSeconds(default45) — how long Kubernetes waits before sendingSIGKILL.app.preStopSleepSeconds(default10) — apreStoplifecycle hook that sleeps before the BEAM receivesSIGTERM, giving the load balancer time to drain the pod from the upstream pool.- Phoenix Endpoint
:drainer— drains in-flight HTTP requests afterSIGTERMbut before the process exits. Configure it inconfig/runtime.exsunderEndpoint, drainer: [shutdown: 30_000].
The math: preStopSleep (10s) + drainer timeout (≤30s) < terminationGracePeriodSeconds (45s).
The HPA is configured with a behavior block to prevent flapping:
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # react immediately to spikes
policies:
- { type: Percent, value: 100, periodSeconds: 15 } # double pods fast
- { type: Pods, value: 2, periodSeconds: 60 }
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 min before scaling down
policies:
- { type: Percent, value: 50, periodSeconds: 60 } # gentle removal
selectPolicy: MinBoth CPU (70%) and memory (80%) metrics are tracked; the HPA takes the maximum across
them. The +sbwt none flag is critical for accurate CPU metrics — without it, idle
busy-waiting inflates reported utilisation and scale-out triggers too early.
When clustering.enabled: true, the chart adds:
- A headless Service (
clusterIP: None) exposing EPMD (4369) and the Erlang distribution port (4370). POD_IP,DNS_CLUSTER_QUERY, andRELEASE_COOKIEenv vars in the Deployment.rel/env.sh.eexsetsRELEASE_NODE=<app>@$POD_IPandRELEASE_DISTRIBUTION=nameso each pod gets a unique node name derived from its IP.
dns_cluster (already a dep) queries the headless Service's DNS A records to discover
peer pods and forms a cluster automatically.
Create the cookie Secret out-of-band before enabling:
kubectl create secret generic my-app-cookie \
--from-literal=cookie=$(openssl rand -hex 32)Then set in your values overlay:
clustering:
enabled: true
cookieSecretName: my-app-cookieThe cookie is a shared RCE-capable credential. Treat it as namespace-trusted only and rotate it with the same discipline as a DB password.
For scaling on request rate or custom Prometheus metrics, KEDA is the
recommended next step. Example ScaledObject (not shipped in the chart — add to your
overlay):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: my-app
spec:
scaleTargetRef:
name: my-app
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: phoenix_endpoint_requests_total
threshold: "500" # requests/s per replica
query: sum(rate(phoenix_endpoint_stop_duration_microseconds_count[1m]))usage_rules (a dev dep) keeps AGENTS.md in sync with the libraries in use. When you
run mix deps.get, updated rules for Elixir, OTP, Phoenix, and Ecto are written to
AGENTS.md automatically — giving AI coding assistants accurate context about the
libraries without manual maintenance.
In development, Tidewave mounts an MCP endpoint at
http://localhost:4647/tidewave/mcp. Connect any MCP-compatible AI tool (Claude Code,
Cursor, etc.) to this URL to give it live access to the running application — inspect
routes, query Ecto, evaluate expressions in the app's context.
The community plugin oliver-kriska/claude-elixir-phoenix adds Elixir- and Phoenix-aware prompting to Claude Code. Install it and the assistant will use idiomatic Elixir patterns, correct OTP supervision trees, and Phoenix 1.7+ conventions by default.