Add a sticky bar at the top of every HTML page that makes it unmistakable when the app is not running in production (e.g. local dev, or a non-production deployment).
Prerequisites
Verify before starting:
- Phoenix 1.8 app with HEEx layouts (a
*Web.Layoutsmodule embeddinglayouts/*) - At least one root layout (
root.html.heex) with a<body>tag - Tailwind CSS (daisyUI optional — see Styling note in Step 2)
App Name Detection
Detect from mix.exs:
- OTP app name (e.g.,
my_app) — used in config keys andApplication.get_env/2 - Web module (e.g.,
MyAppWeb) — the module holdingEndpointandLayouts
Replace all my_app / MyAppWeb template references accordingly.
Phase 0: Discovery
CRITICAL: Before generating anything, audit the target app.
- Existing banner —
Grep("env_banner")andGrep("show_env_banner")inlib/. If found: report what exists and skip. - Layouts module — Find the module that does
embed_templates("layouts/*")(usuallylib/my_app_web/components/layouts.ex). This is where helpers + the component go. - Root layouts — Find every root layout (
Grep("<body")inlib/**/components/layouts/*.html.heex). There may be more than one (e.g.root.html.heex,public_root.html.heex). The banner must go in each. - Fixed/sticky top chrome — In layout templates,
Grep("inset-y-0")andGrep("sticky top-0"). Any element fixed/sticky to the top will overlap a top banner and needs a top offset (see Step 4). Note their file + line. - Production host — Read
config/runtime.exs/fly.toml/ deploy config forPHX_HOST. Note the real production hostname to suggest as the default. - Styling — Check
assets/css/app.cssfordaisyui. Note whether daisyUI semantic classes (bg-warning) are available or plain Tailwind is needed.
Discovery Report
Present findings (app name, layouts module, every root layout, any fixed/sticky top chrome, detected production host) and confirm before proceeding.
Phase 1: Configuration
Ask the user (lead with the detected defaults):
-
Detection strategy — When should the banner show?
- Host-based (recommended): show unless the endpoint's configured host equals
the production host. Correct when a prod release runs on multiple/non-prod hosts
(e.g. a staging deploy running
MIX_ENV=prod). Asks for the production host. - Mix-env based: show whenever
Application.get_env(:my_app, :env) != :prod. Simpler, but hides the banner on any deployment built withMIX_ENV=prod(including staging). Requiresconfig :my_app, :env, config_env()inconfig.exs.
- Host-based (recommended): show unless the endpoint's configured host equals
the production host. Correct when a prod release runs on multiple/non-prod hosts
(e.g. a staging deploy running
-
Production host (host-based only) — The canonical production hostname. Default: the
PHX_HOSTfound in Phase 0 (e.g.example.com). -
Banner label — What text to show. Default:
"NON-PRODUCTION · <env> · <host>"(env + endpoint host, computed at render). -
Dismissible? — Default: no (always visible so it can't be accidentally hidden).
Phase 2: Implementation
Step 1: Add config
Host-based — add to config/config.exs:
# Canonical production host. Used to decide whether to show the non-production
# banner (see MyAppWeb.Layouts.show_env_banner?/0).
config :my_app, :production_host, "example.com"
Optionally allow a runtime override by reading System.get_env("PRODUCTION_HOST", "example.com")
in config/runtime.exs instead — only if the host varies per deploy.
Mix-env based — ensure config/config.exs has (add if missing):
config :my_app, :env, config_env()
Step 2: Add helpers + component to the Layouts module
Add to MyAppWeb.Layouts (the module that does embed_templates), right after the
embed_templates("layouts/*") line:
@doc """
Renders a sticky "non-production" warning bar at the top of every page.
Shown on every host except the canonical production host.
"""
def env_banner(assigns) do
~H"""
<div
:if={show_env_banner?()}
class="sticky top-0 z-[100] flex h-7 w-full items-center justify-center gap-1.5 bg-warning text-warning-content text-xs font-semibold tracking-wide"
>
<.icon name="hero-exclamation-triangle-mini" class="size-3.5" />
{env_banner_label()}
</div>
"""
end
@doc "Whether the non-production environment banner should be shown."
@spec show_env_banner?() :: boolean()
def show_env_banner? do
# HOST-BASED:
MyAppWeb.Endpoint.host() != Application.fetch_env!(:my_app, :production_host)
# MIX-ENV BASED (use this line instead):
# Application.get_env(:my_app, :env) != :prod
end
@spec env_banner_label() :: String.t()
defp env_banner_label do
env = Application.get_env(:my_app, :env)
"NON-PRODUCTION · #{env} · #{MyAppWeb.Endpoint.host()}"
end
show_env_banner?/0 must be public — fixed/sticky chrome in embedded layout
templates (Step 4) calls it. Keep env_banner_label/0 private.
Styling note: bg-warning / text-warning-content are daisyUI classes. If the
app does not use daisyUI, swap them for plain Tailwind, e.g.
bg-amber-400 text-black. The <.icon> heroicon assumes the Phoenix default
core_components icon component; drop it if unavailable.
Why host-based uses Endpoint.host(): it returns the endpoint's configured
:url host — a fixed deployment-level value, NOT the request's Host header. So
custom domains / subdomains served by the production app all resolve to the
production host and stay hidden; on a non-prod deploy they all show the banner.
Step 3: Render the banner in every root layout
In each root layout found in Phase 0, insert <.env_banner /> as the first
child of <body>:
<body>
<.env_banner />
{@inner_content}
</body>
(If a layout already has content right after <body>, place <.env_banner />
before it.)
Step 4: Offset fixed/sticky top chrome
The banner is pinned (sticky top-0, height h-7 = 1.75rem). Any element from
Phase 0 that is fixed or sticky to the top of the viewport will sit under the
banner and must be offset by the banner height only when the banner shows.
Use a conditional class with show_env_banner?/0:
- Fixed full-height sidebar — replace
lg:inset-y-0with:class={["...", if(show_env_banner?(), do: "lg:top-7 lg:bottom-0", else: "lg:inset-y-0")]} - Sticky top header/topbar — replace
top-0with:class={["...", if(show_env_banner?(), do: "top-7", else: "top-0")]}
Pick the offset (top-7) to match the banner's height class (h-7). Layouts with
only normal-flow content (public pages, auth pages) need no offset — the sticky
banner pins to the top and content flows beneath it.
Step 5: Verify
mix compile— clean.mix phx.server, open the app locally: the bar readsNON-PRODUCTION · dev · localhoston a public page, an auth page, and any dashboard/admin page. Confirm fixed/sticky chrome sits fully below the bar.- Host-based: temporarily set
:production_hostto"localhost"and confirm the bar disappears, then revert. Optionally add a test assertingMyAppWeb.Layouts.show_env_banner?/0isfalsewhen the endpoint host equals:production_host. mix formatand run the test suite.
Summary of Changes
| File | Change |
|---|---|
config/config.exs |
Add :production_host (host-based) or :env (mix-env) config |
lib/my_app_web/components/layouts.ex |
Add env_banner/1, show_env_banner?/0, env_banner_label/0 |
Each *root*.html.heex |
Add <.env_banner /> as first child of <body> |
| Layouts with fixed/sticky top chrome | Offset by top-7 when show_env_banner?/0 |
Config Reference
| Key | Strategy | Meaning |
|---|---|---|
:production_host |
host-based | Banner hidden only when Endpoint.host() equals this |
:env (config_env()) |
mix-env | Banner shown when != :prod |