Deploy a Elixir Service to Kubernetes

Khimananda Oli 10 min read Programming and Languages
Deploy a Elixir Service to Kubernetes

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.

Ingress ControllerTLS TerminationElixir Pod ABEAM ReleaseTelemetry + HealthElixir Pod BBEAM ReleaseTelemetry + HealthPostgreSQL / RedisManaged DB / CacheTraffic Flow: Ingress → Pods → Data Stores
High-level architecture when you deploy a Elixir service to Kubernetes, showing traffic flow through ingress to replicated BEAM pods and backend data stores.

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/live that 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.

KubeletPhoenix AppPostgreSQLGET /healthz/live200 OKGET /healthz/readySELECT 1Result200 ReadyAdd to Service
Health check sequence when you deploy a Elixir service to Kubernetes: liveness validates the VM, readiness confirms dependency connectivity before traffic routing.

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 emulator
  • vm_scheduler_utilization_percent — Percentage of time schedulers spend executing
  • vm_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.

Naive DeploymentSingle-stage image with Mix includedNo health checks configuredSecrets baked into image layersNo resource limits setGeneric CPU/memory metrics onlyProduction DeploymentMulti-stage distillery release imageLiveness + readiness probes activeRuntime config + K8s Secrets/VaultRequests and limits enforcedBEAM telemetry + OTel traces
Side-by-side comparison highlighting the differences between naive and production-grade approaches when you deploy a Elixir service to Kubernetes.

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.

ConcernNaive ApproachProduction Pattern
Image Size~800MB with build tools~150MB runtime-only release
Memory LimitsIgnored by pre-OTP27 BEAMCgroup-aware allocation in OTP27+
Secret ManagementEnvironment variables in YAMLExternal secrets operator + Vault
Health ChecksTCP socket probe onlyHTTP liveness + dependency-aware readiness
ObservabilityRequest logs onlyBEAM 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.

Frequently Asked Questions

Use a multi-stage Dockerfile with hexpm/elixir:1.18-alpine as the build stage and alpine:3.20 for runtime. Build the release with MIX_ENV=prod mix release, then copy only the _build/prod/rel directory to the final image to keep it under 150MB.

The hexpm/elixir images are optimized for production releases and include OTP precompiled. Avoid using full erlang or elixir official images as they lack release tooling integration and result in significantly larger container sizes and slower pod startup times in cluster environments.

Expose a /health endpoint using Plug.Cowboy or Bandit returning 200 OK. Configure livenessProbe and readinessProbe in your deployment manifest with initialDelaySeconds set to 10 to account for BEAM VM warmup and application supervision tree initialization time.

No, Elixir handles concurrency natively via the BEAM scheduler. Skip Envoy or Istio sidecars unless you require mutual TLS or advanced traffic splitting, as they add memory overhead and latency without improving native Erlang distribution or GenServer performance characteristics.

Mount Kubernetes Secrets as environment variables or files. Configure your runtime.exs to read System.get_env at boot time rather than compile time. Never bake credentials into the release artifact; use external secret operators like External Secrets Operator for vault integration.

Set memory requests equal to limits to prevent OOM kills during garbage collection spikes. Allocate CPU based on actual schedulers; typically 2 cores for most web services. Enable ERTS_MULTI_DIRTY_SCHEDULERS for CPU-bound NIFs to avoid blocking normal schedulers.

Set terminationGracePeriodSeconds to 30 and trap exits in your Application module. Implement a shutdown callback that stops accepting new connections, drains in-flight requests, and persists state before the SIGTERM deadline expires to prevent data loss.

Yes, but it requires DNS-based clustering via libcluster with the Kubernetes strategy. Configure epmd port 4369 and distribution port range in network policies. Note that distribution adds complexity; prefer HTTP or message queues for most service-to-service communication patterns.

Use a Kubernetes initContainer running the same image with command mix ecto.migrate. This ensures schema changes complete before the main application boots, preventing race conditions during rolling deployments where old and new code versions coexist temporarily.

Output structured JSON logs to stdout using LoggerJSON or similar backends. Include trace_id, span_id, and pod metadata. Avoid file logging entirely since containers are ephemeral; let Fluent Bit or Vector collect stdout streams for centralized aggregation and indexing.

Check kubectl logs for crash dumps and observer_cli output. Enable SASL error logger reports in config. If the BEAM hangs, exec into the pod and run :observer.start or use remsh to attach a remote shell for live inspection without restarting.

Yes, but ensure WebSocket connections are sticky via ingress annotations or session affinity. Configure pubsub adapters like Redis or Postgres for multi-pod broadcast since default PG2 only works within single nodes. Test connection recovery under pod restart scenarios thoroughly.

Scale based on custom metrics like mailbox length or connection count via Prometheus adapter, not just CPU. Stateless services scale linearly; stateful GenServers require partitioning strategies or CRDTs. Always validate clustering behavior after scaling events to prevent split-brain issues.

NGINX Ingress Controller with websocket annotations enabled handles upgrades reliably. Set proxy-read-timeout to match your longest expected idle connection. Avoid cloud load balancers without explicit WebSocket support as they often terminate long-lived connections prematurely during traffic shifts.

Deploy prometheus_ex with telemetry_poller to expose vm_memory, scheduler_utilization, and process counts. Scrape via ServiceMonitor. Alert on high message queue lengths or memory fragmentation rather than generic CPU thresholds to catch BEAM-specific bottlenecks before user impact occurs.