
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
You built your application on the BEAM VM, but now you need to ship it reliably at scale. The challenge isn't just running Erlang/Elixir in a container; it is configuring the runtime to respect cluster boundaries while maintaining fault tolerance. To successfully deploy a Elixir service to Kubernetes, you must align OTP release mechanics with pod lifecycles, resource limits, and distributed tracing. This guide covers the production patterns I use daily, moving beyond basic tutorials to address the specific friction points of stateful BEAM applications in orchestrated environments.
How do you build an optimized Elixir Docker image for Kubernetes?
The most common mistake teams make when they first reduce Docker image size for Elixir is skipping the final runtime-only stage. Your production container should never contain Mix, Hex, or build tools. You want an immutable OTP release artifact that starts in milliseconds, not minutes.
Multi-stage Dockerfile pattern
This Dockerfile targets Elixir 1.17+ and Erlang/OTP 27, which have native support for reading cgroup v2 limits. This is critical because older versions ignore Kubernetes memory limits, leading to OOM kills during garbage collection spikes.
# Build stage
FROM hexpm/elixir:1.17.2-erlang-27.0-debian-bookworm-20240701 AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix local.hex --force && mix local.rebar --force && mix deps.get --only prod
COPY config/config.exs config/prod.exs config/
RUN mix deps.compile
COPY lib lib
COPY priv priv
RUN mix compile
# Assemble release
RUN mix release
# Runtime stage
FROM debian:bookworm-slim AS runtime
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
WORKDIR /app
RUN chown nobody /app
# Copy only the release tarball
COPY --from=builder --chown=nobody:nobody /app/_build/prod/rel/my_app ./
USER nobody
# Enable BEAM to respect container limits (OTP 27+)
ENV ERL_AFLAGS="+fnu +sbwt none +sbwtdcpu none"
ENV RELEASE_DISTRIBUTION=name
ENV RELEASE_NODE=my_app@${POD_IP}
EXPOSE 4000
CMD ["bin/my_app", "start"] Key details in this configuration deserve attention. The ERL_AFLAGS environment variable tunes the scheduler busy wait threshold to reduce idle CPU burn in shared environments. Setting RELEASE_NODE dynamically using the pod IP enables Erlang distribution without hardcoded hostnames, which is essential if you plan to use libcluster for node discovery later. Always run as a non-root user; this satisfies SOC 2 and ISO 27001 control requirements for least privilege.
How do you configure Kubernetes health checks for Phoenix applications?
Kubernetes doesn't know what "healthy" means for an Elixir application. A running BEAM process might be deadlocked, disconnected from the database, or stuck in a failed initialization state. You must expose explicit endpoints that validate actual operational readiness.
Liveness vs. readiness distinction
- Liveness probe: Answers "Is the BEAM VM alive?" Use a simple endpoint like
/healthz/livethat returns 200 immediately. Do NOT check external dependencies here. If the database is down, restarting your pod won't fix it and will cause cascading failures. - Readiness probe: Answers "Can this pod serve traffic safely?" Check database connectivity, cache availability, and migration status at
/healthz/ready. Return 503 until all checks pass.
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def live(conn, _params) do
# Simple liveness: VM is responsive
json(conn, %{status: "ok"})
end
def ready(conn, _params) do
checks = [
db: fn -> Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1") end,
redis: fn -> Redix.ping(:redix_instance) end
]
results = Enum.map(checks, fn {name, check} ->
case check.() do
{:ok, _} -> {name, :ok}
{:error, reason} -> {name, {:error, reason}}
end
end)
if Enum.all?(results, fn {_k, v} -> v == :ok end) do
json(conn, %{status: "ready", checks: results})
else
conn
|> put_status(:service_unavailable)
|> json(%{status: "not_ready", checks: results})
end
end
end Kubernetes manifest probes
Configure conservative initial delays. Elixir releases can take 5–15 seconds to boot depending on supervision tree complexity and migration execution. Premature probing causes restart loops.
spec:
containers:
- name: my-app
ports:
- containerPort: 4000
livenessProbe:
httpGet:
path: /healthz/live
port: 4000
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: 4000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m" Always set both requests and limits. Without limits, the BEAM VM may attempt to allocate memory beyond the node's capacity, triggering kernel OOM killer behavior that bypasses graceful shutdown hooks. For detailed guidance on sizing, see Kubernetes resource limits and requests.
How do you manage secrets and environment variables securely?
Elixir releases bake configuration into the binary at build time by default. This conflicts with twelve-factor principles and creates security risks when sensitive values appear in container images or Git history. In production, you must separate secrets from code entirely.
Runtime configuration with Config.Provider
Use Config.Provider to load secrets at boot time rather than compile time. This allows the same container image to run across staging and production with different credentials injected via environment variables or mounted volumes.
# config/runtime.exs
import Config
if config_env() == :prod do
secret_key_base = System.fetch_env!("SECRET_KEY_BASE")
database_url = System.fetch_env!("DATABASE_URL")
config :my_app, MyAppWeb.Endpoint,
server: true,
secret_key_base: secret_key_base,
url: [host: System.get_env("PHX_HOST"), port: String.to_integer(System.get_env("PORT") || "4000")]
config :my_app, MyApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
ssl: String.to_existing_atom(System.get_env("DB_SSL") || "false")
end Kubernetes Secrets integration
Never hardcode secrets in manifests. Reference Kubernetes Secrets objects and mount them as environment variables or files. For higher security postures aligned with Kubernetes secrets management done right, integrate HashiCorp Vault or AWS Secrets Manager via CSI drivers to avoid storing plaintext in etcd.
envFrom:
- secretRef:
name: my-app-prod-secrets
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: PHX_HOST
value: "api.example.com" Audit access to these secrets. In regulated environments, every secret read should generate a log entry traceable to a specific pod identity. This matters during compliance reviews and incident forensics.
How do you implement observability for BEAM services in Kubernetes?
The BEAM VM exposes rich internal metrics that generic monitoring tools miss. Scheduler utilization, message queue lengths, atom table usage, and GC pressure are leading indicators of problems that HTTP latency alone won't reveal until users complain.
OpenTelemetry instrumentation
Add :opentelemetry and :opentelemetry_exporter to your dependencies. Configure exporters to send traces to Tempo or Jaeger and metrics to Prometheus. See instrumenting apps with OpenTelemetry for foundational setup patterns applicable across languages.
# config/config.exs
config :opentelemetry,
span_processor: :batch,
traces_exporter: :otlp
config :opentelemetry_exporter,
otlp_protocol: :http_protobuf,
otlp_endpoint: "http://otel-collector.monitoring:4318" Prometheus metrics exposure
Expose a /metrics endpoint using telemetry_metrics_prometheus_core. Include both standard HTTP request metrics and BEAM-specific gauges:
vm_memory_total_bytes— Total memory allocated by the emulatorvm_scheduler_utilization_percent— Percentage of time schedulers spend executingvm_message_queue_length— Messages waiting per process (high values indicate bottlenecks)ecto_query_duration_milliseconds— Database query latency histograms
Create alerts based on these signals. Scheduler utilization above 80% sustained for five minutes predicts latency degradation better than CPU percentage alone. Message queue growth rates catch deadlocks before timeouts cascade. Refer to the four golden signals of monitoring to prioritize which metrics warrant paging engineers at night.
What are common pitfalls when scaling Elixir on Kubernetes?
The BEAM was designed for long-lived nodes with stable identities. Kubernetes treats pods as ephemeral cattle. Bridging this philosophical gap requires deliberate architectural choices.
Distribution and clustering challenges
If your application uses Phoenix PubSub, Presence, or libcluster for node-to-node communication, pod restarts create partition risks. DNS-based discovery strategies often lag behind actual pod lifecycle events by 30–60 seconds. Consider using the Kubernetes API strategy with proper RBAC permissions, or evaluate whether you actually need full mesh clustering. Many applications function correctly with Redis-backed pubsub and stateless workers, eliminating distribution complexity entirely.
Connection pooling under autoscaling
When Horizontal Pod Autoscaler adds replicas rapidly, each new pod opens database connections simultaneously. A sudden scale-out from 3 to 15 pods can exhaust your RDS or Cloud SQL connection limit before rate limiting engages. Set conservative pool_size values relative to your database max_connections divided by maximum expected replicas. Implement connection draining during scale-in to prevent abandoned transactions. Review horizontal pod autoscaling in Kubernetes for tuning stabilization windows that match Elixir's warm-up characteristics.
Graceful shutdown handling
Kubernetes sends SIGTERM with a default 30-second grace period. Your release must trap exits and complete in-flight requests within this window. Configure shutdown_timeout in your endpoint supervisor and ensure GenServers implement terminate/2 callbacks to flush buffers and acknowledge messages. Test shutdown behavior explicitly; silent data loss during deployments is unacceptable in financial or healthcare systems.
| Concern | Naive Approach | Production Pattern |
|---|---|---|
| Image Size | ~800MB with build tools | ~150MB runtime-only release |
| Memory Limits | Ignored by pre-OTP27 BEAM | Cgroup-aware allocation in OTP27+ |
| Secret Management | Environment variables in YAML | External secrets operator + Vault |
| Health Checks | TCP socket probe only | HTTP liveness + dependency-aware readiness |
| Observability | Request logs only | BEAM metrics + distributed traces + SLOs |
Deploy a Elixir Service to Kubernetes With Confidence
Shipping Elixir to Kubernetes demands respect for both the platform's ephemerality and the BEAM's operational semantics. Get the Docker build right, instrument deeply, manage secrets properly, and validate health explicitly. These foundations let you focus on business logic instead of firefighting infrastructure mismatches. If your team needs hands-on guidance implementing these patterns for regulated workloads or high-throughput systems, reach out to discuss your deployment architecture.