---
name: deployment-gotchas
description: Use when preparing releases or deployment config — runtime.exs vs compile-time config, release migrations, PHX_HOST/PHX_SERVER, assets, health checks.
file_patterns:
  - "**/config/*.exs"
  - "**/rel/**"
  - "**/Dockerfile"
  - "**/docker-compose*.yml"
auto_suggest: true
---

# Deployment Gotchas

Not a deployment guide — these are the 7 things that break every first Phoenix deploy. Every rule maps to a real production incident pattern.

## RULES — Follow these with no exceptions

1. **Use `runtime.exs` for secrets and URLs** — `config.exs`/`prod.exs` are compiled into the release and cannot read env vars at boot
2. **Run migrations via release commands (`bin/migrate`)** — `mix` is not available in production releases
3. **Set `PHX_HOST` and `PHX_SERVER=true`** — without these, URL generation breaks and the server won't start
4. **Run `mix assets.deploy` before building the release** — forgetting this means no CSS/JS in production
5. **Never hardcode secrets** — use `System.fetch_env!/1` in `runtime.exs` (the `!` crashes on boot if missing, which is what you want)
6. **Split health checks into liveness and readiness** — liveness returns 200 without touching the DB; only readiness queries the database
7. **Use `config :logger, level: :info` in production** — `:debug` logs query parameters including user data

---

## 1. runtime.exs vs config.exs

**The incident:** App deploys fine but uses the wrong database URL. `DATABASE_URL` was set correctly in the environment, but the release ignores it.

**Why:** `config.exs` and `prod.exs` are evaluated at **compile time** and baked into the release. `runtime.exs` is evaluated at **boot time** and can read environment variables.

**Bad:**
```elixir
# config/prod.exs — compiled into release, cannot read env vars at boot
config :my_app, MyApp.Repo,
  # Evaluated at BUILD time — captures the build machine's env, not the
  # runtime env. Silently wrong in a release; use runtime.exs instead.
  url: System.get_env("DATABASE_URL")
```

**Good:**
```elixir
# config/runtime.exs — evaluated at boot, reads env vars correctly
if config_env() == :prod do
  database_url = System.fetch_env!("DATABASE_URL")

  config :my_app, MyApp.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end
```

**Rule of thumb:** If the value comes from the environment, it goes in `runtime.exs`. If it's a static setting, it goes in `config.exs`.

---

## 2. Release Migrations

**The incident:** Deploy succeeds but the app crashes on boot because new columns don't exist. Developer tries `mix ecto.migrate` on the server — `mix: command not found`.

**Why:** Production releases don't include Mix or the Elixir compiler. Migrations must be run via release commands.

**Bad:**
```bash
# mix is not available in production releases
ssh prod-server "cd /app && mix ecto.migrate"
```

**Good:**
```elixir
# lib/my_app/release.ex
defmodule MyApp.Release do
  @app :my_app

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.ensure_all_started(:ssl)
    Application.load(@app)
  end
end
```

```bash
# Run migrations in production
bin/my_app eval "MyApp.Release.migrate()"

# Or via rel/overlays if configured
bin/migrate
```

---

## 3. PHX_HOST and PHX_SERVER

**The incident:** Deploy succeeds, health check passes, but all URLs in emails and redirects point to `localhost:4000`. Or worse — the server doesn't start at all.

**Why:** Without `PHX_SERVER=true`, the Phoenix endpoint doesn't start its HTTP listener. Without `PHX_HOST`, URL helpers generate `localhost` URLs.

**Bad:**
```elixir
# config/runtime.exs — missing host and server config
config :my_app, MyAppWeb.Endpoint,
  url: [host: "localhost"],  # Wrong in production!
  http: [port: 4000]
  # Server doesn't start without server: true
```

**Good:**
```elixir
# config/runtime.exs
if config_env() == :prod do
  host = System.fetch_env!("PHX_HOST")
  port = String.to_integer(System.get_env("PORT") || "4000")

  config :my_app, MyAppWeb.Endpoint,
    url: [host: host, port: 443, scheme: "https"],
    http: [ip: {0, 0, 0, 0}, port: port],
    server: true  # Or set PHX_SERVER=true env var
end
```

---

## 4. Asset Deployment

**The incident:** App deploys, pages load, but CSS/JS are missing. The page is unstyled raw HTML.

**Why:** Assets must be compiled and digested before the release is built. The release bundles `priv/static` — if assets aren't there at build time, they won't be in the release.

**Bad:**
```dockerfile
# Dockerfile — builds release without compiling assets
RUN mix release
```

**Good:**
```dockerfile
# Dockerfile — correct order
RUN mix assets.deploy
RUN mix release
```

```bash
# Manual build order
mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy  # Must come before release
MIX_ENV=prod mix release
```

**What `mix assets.deploy` does:**
1. Runs `tailwind` and `esbuild` to compile CSS/JS
2. Runs `phx.digest` to fingerprint files for cache busting
3. Generates `cache_manifest.json` for the endpoint to serve

---

## 5. Never Hardcode Secrets

**The incident:** Secret key leaks into git history via `config/prod.exs`. Rotating it requires a new release.

**Why:** Secrets in compiled config are baked into the release binary and visible in version control.

**Bad:**
```elixir
# config/prod.exs — secret in source code
config :my_app, MyAppWeb.Endpoint,
  secret_key_base: "actual_secret_key_here_in_git_history"
```

**Good:**
```elixir
# config/runtime.exs — read from environment, crash if missing
if config_env() == :prod do
  secret_key_base = System.fetch_env!("SECRET_KEY_BASE")

  config :my_app, MyAppWeb.Endpoint,
    secret_key_base: secret_key_base
end
```

**Why `fetch_env!` (with bang):** If the secret is missing, the app crashes immediately on boot with a clear error. Plain `System.get_env/1` returns `nil` when missing and fails later with a confusing error.

```bash
# Generate a secret
mix phx.gen.secret

# Set in environment (never in source)
export SECRET_KEY_BASE="generated_secret_here"
```

---

## 6. Health Endpoints

**The incident:** Load balancer reports the app is healthy, but users see 500 errors. The app boots fine but can't connect to the database.

**Why:** A simple `200 OK` endpoint proves the HTTP server started but nothing else. A health check that queries the database proves the full stack works.

**Bad:**
```elixir
# Just proves the server started
get "/health", PageController, :health

def health(conn, _params) do
  send_resp(conn, 200, "OK")
end
```

**Liveness vs readiness:** a load balancer *liveness* probe should return 200
without touching the database — a transient DB blip must not remove the whole
fleet. Point deep checks (DB query below) at a *readiness* probe only.

**Good:**
```elixir
# router.ex
get "/health/live", HealthController, :live
get "/health/ready", HealthController, :ready

# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  # Liveness: proves the BEAM is up and the endpoint is responding.
  # No DB query — a slow/unavailable database must not take down the
  # whole fleet just because one instance can't reach it.
  def live(conn, _params) do
    send_resp(conn, 200, "OK")
  end

  # Readiness: proves this instance can actually serve traffic.
  def ready(conn, _params) do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1") do
      {:ok, _} ->
        json(conn, %{status: "ok", database: "connected"})

      {:error, reason} ->
        conn
        |> put_status(:service_unavailable)
        |> json(%{status: "error", database: inspect(reason)})
    end
  end
end
```

**Configure your load balancer** with two probes: liveness at `/health/live`
(restart the instance if this fails) and readiness at `/health/ready` (stop
routing traffic to this instance if this fails, but don't restart it — the
rest of the fleet may still be healthy). Collapsing both into one `/health`
endpoint means a DB blip either gets masked (if it's a shallow check) or
takes healthy instances out of rotation right when the DB needs the load
to drop (if it's a deep check without the liveness/readiness split).

---

## 7. Production Log Level

**The incident:** App runs fine but storage costs spike. Investigation reveals debug logs are writing gigabytes per day, including full SQL queries with user data (emails, addresses).

**Why:** Ecto logs all queries at `:debug` level, including query parameters. In production, this means PII in your logs.

**Bad:**
```elixir
# config/prod.exs
config :logger, level: :debug  # Logs everything including query params
```

**Good:**
```elixir
# config/prod.exs
config :logger, level: :info

# config/runtime.exs — allow override for debugging
if config_env() == :prod do
  log_level =
    case System.get_env("LOG_LEVEL") do
      "debug" -> :debug
      "warning" -> :warning
      "error" -> :error
      _ -> :info
    end

  config :logger, level: log_level
end
```

**What each level includes:**
- `:debug` — SQL queries with parameters, internal state, PII risk
- `:info` — Request lifecycle, business events (recommended for production)
- `:warning` — Recoverable problems
- `:error` — Failures requiring attention

---

## Not Covered (Intentionally)

This skill does not cover platform-specific deployment:
- Docker/Dockerfile patterns → see official Phoenix deployment guides
- Fly.io, Gigalixir, Render setup → see platform documentation
- Kubernetes manifests → see your infra team's docs
- CI/CD pipeline configuration → project-specific

These are deployment-platform docs, not Phoenix-specific gotchas.

---

See `telemetry-essentials` skill for production logging and observability patterns.
See `security-essentials` skill for secrets management and dependency auditing.
