Skip to content

Repository files navigation

phoenix-starter

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.

CI

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.


Quickstart

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).


Example domain — CRM (Account → Contact)

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.

Context seam

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.Ecto

Two 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.


Why Ecto

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 queriesimport Ecto.Query lets 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!/2 accepts 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.


Release migrations

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.


Why Elixir

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.


Running on Kubernetes

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: true and configure ingress.base / ingress.host.
  • HPA — CPU + memory metrics with anti-flap behavior block (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-secrets
kubectl create secret generic my-app-secrets \
  --from-literal=SECRET_KEY_BASE=$(openssl rand -hex 64) \
  --from-literal=DATABASE_URL=ecto://user:pass@host/db

Tuning the BEAM for scale

Scheduler pinning (+S)

When 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.

Dirty-IO schedulers (+SDio 4)

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.

Kernel poll (+K true)

Enables epoll-based I/O multiplexing. Reduces system call overhead under high connection counts.

Busy-wait off (+sbwt none)

+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.

Memory allocator flags (beam.memoryOptimized)

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.

Extra flags (beam.extraErlFlags)

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.

Graceful shutdown

Three settings work together:

  1. app.terminationGracePeriodSeconds (default 45) — how long Kubernetes waits before sending SIGKILL.
  2. app.preStopSleepSeconds (default 10) — a preStop lifecycle hook that sleeps before the BEAM receives SIGTERM, giving the load balancer time to drain the pod from the upstream pool.
  3. Phoenix Endpoint :drainer — drains in-flight HTTP requests after SIGTERM but before the process exits. Configure it in config/runtime.exs under Endpoint, drainer: [shutdown: 30_000].

The math: preStopSleep (10s) + drainer timeout (≤30s) < terminationGracePeriodSeconds (45s).


Autoscaling

HPA anti-flap behavior

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: Min

Both 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.

Clustering (Phoenix.PubSub / Presence across replicas)

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, and RELEASE_COOKIE env vars in the Deployment.
  • rel/env.sh.eex sets RELEASE_NODE=<app>@$POD_IP and RELEASE_DISTRIBUTION=name so 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-cookie

The cookie is a shared RCE-capable credential. Treat it as namespace-trusted only and rotate it with the same discipline as a DB password.

KEDA (request-rate / custom metrics)

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]))

AI-assisted development

usage_rules

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.

Tidewave MCP

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.

Elixir + Phoenix plugin

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.

About

Production-ready Elixir + Phoenix 1.8 service template — config-swappable context seam (Ecto/Fake), mix-release Docker, a Kubernetes/Helm chart with BEAM-at-scale tuning + autoscaling, GitHub Actions CI, and AI dev tooling pre-wired.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages