Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: deployment-gotchas description: MANDATORY for deployment and release configuration. Invoke before modifying config/, rel/, or Dockerfile. 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
- Use `runtime.exs` for secrets and URLs —
config.exs/prod.exsare compiled into the release and cannot read env vars at boot - Run migrations via release commands (`bin/migrate`) —
mixis not available in production releases - Set `PHX_HOST` and `PHX_SERVER=true` — without these, URL generation breaks and the server won't start
- Run `mix assets.deploy` before building the release — forgetting this means no CSS/JS in production
- Never hardcode secrets — use
System.get_env!/1inruntime.exs(the!crashes on boot if missing, which is what you want) - Add a `/health` endpoint that queries the database — load balancers need it, and a 200-only check hides DB connection failures
- Use `config :logger, level: :info` in production —
:debuglogs 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:
# config/prod.exs — compiled into release, cannot read env vars at bootconfig :my_app, MyApp.Repo,url: System.get_env("DATABASE_URL") # Always nil in release!
Good:
# config/runtime.exs — evaluated at boot, reads env vars correctlyif config_env() == :prod dodatabase_url = System.get_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:
# mix is not available in production releasesssh prod-server "cd /app && mix ecto.migrate"
Good:
# lib/my_app/release.exdefmodule MyApp.Release do@app :my_appdef migrate doload_app()for repo <- repos() do{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))endenddef rollback(repo, version) doload_app(){:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))enddefp repos doApplication.fetch_env!(@app, :ecto_repos)enddefp load_app doApplication.ensure_all_started(:ssl)Application.load(@app)endend
# Run migrations in productionbin/my_app eval "MyApp.Release.migrate()"# Or via rel/overlays if configuredbin/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:
# config/runtime.exs — missing host and server configconfig :my_app, MyAppWeb.Endpoint,url: [host: "localhost"], # Wrong in production!http: [port: 4000]# Server doesn't start without server: true
Good:
# config/runtime.exsif config_env() == :prod dohost = System.get_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 varend
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 — builds release without compiling assetsRUN mix release
Good:
# Dockerfile — correct orderRUN mix assets.deployRUN mix release
# Manual build ordermix deps.get --only prodMIX_ENV=prod mix compileMIX_ENV=prod mix assets.deploy # Must come before releaseMIX_ENV=prod mix release
What `mix assets.deploy` does:
- Runs
tailwindandesbuildto compile CSS/JS - Runs
phx.digestto fingerprint files for cache busting - Generates
cache_manifest.jsonfor 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:
# config/prod.exs — secret in source codeconfig :my_app, MyAppWeb.Endpoint,secret_key_base: "actual_secret_key_here_in_git_history"
Good:
# config/runtime.exs — read from environment, crash if missingif config_env() == :prod dosecret_key_base = System.get_env!("SECRET_KEY_BASE")config :my_app, MyAppWeb.Endpoint,secret_key_base: secret_key_baseend
Why `get_env!` (with bang): If the secret is missing, the app crashes immediately on boot with a clear error. Without the bang, it starts with nil and fails later with a confusing error.
# Generate a secretmix 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:
# Just proves the server startedget "/health", PageController, :healthdef health(conn, _params) dosend_resp(conn, 200, "OK")end
Good:
# router.exget "/health", HealthController, :check# lib/my_app_web/controllers/health_controller.exdefmodule MyAppWeb.HealthController douse MyAppWeb, :controllerdef check(conn, _params) docase 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)})endendend
Configure your load balancer to hit /health and expect a 200. If the database goes down, the health check fails and the load balancer stops routing traffic.
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:
# config/prod.exsconfig :logger, level: :debug # Logs everything including query params
Good:
# config/prod.exsconfig :logger, level: :info# config/runtime.exs — allow override for debuggingif config_env() == :prod dolog_level =case System.get_env("LOG_LEVEL") do"debug" -> :debug"warning" -> :warning"error" -> :error_ -> :infoendconfig :logger, level: log_levelend
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.