How to Block AI Bots on Elixir Phoenix: Complete 2026 Guide
Phoenix processes every request through a Plug pipeline — a chain of composable functions that transform the connection struct. Bot blocking is a custom Plug inserted into that chain. This guide covers every layer: serving robots.txt via Plug.Static, hard-blocking with a custom Plug module, adding noai meta tags in HEEx layouts, setting X-Robots-Tag headers, LiveView server-rendering considerations, and deployment on Fly.io, Gigalixir, and Docker.
Phoenix 1.7+
All examples target Phoenix 1.7+ with HEEx templates and verified routes. Phoenix 1.7 replaced app.html.eex with root.html.heex and introduced the ~p sigil for verified routes. The Plug API is identical across all Phoenix versions.
Methods at a glance
| Method | What it does | Blocks JS-less bots? |
|---|---|---|
| Plug.Static → robots.txt | Signals crawlers to stay out | Signal only |
| noai meta in root.html.heex | Opt out of AI training site-wide | ✓ (server-rendered) |
| Per-page assigns override | noai on specific pages only | ✓ (server-rendered) |
| X-Robots-Tag pipeline plug | noai header on all responses | ✓ (header) |
| Custom Plug in endpoint.ex | Hard 403 globally — before routing | ✓ |
| Router pipeline plug | Hard 403 for matched routes only | ✓ |
| nginx map block | Hard 403 at reverse proxy layer | ✓ |
1. robots.txt — Plug.Static
Place robots.txt in priv/static/. Phoenix's default endpoint includes Plug.Static which serves files from this directory at the root URL path. No code changes needed — just drop the file in.
The "only:" whitelist gotcha
If your Plug.Static in endpoint.ex uses the only: option to whitelist files, add "robots.txt" to that list. Without it, Plug.Static silently skips the file and returns 404.
priv/static/robots.txt
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: OAI-SearchBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Applebot-Extended
Disallow: /
User-agent: *
Allow: /lib/my_app_web/endpoint.ex — verify Plug.Static config
# lib/my_app_web/endpoint.ex
# Default Phoenix endpoint already has this — just verify it includes robots.txt
plug Plug.Static,
at: "/",
from: :my_app,
gzip: false,
# If you use "only:", make sure robots.txt is listed:
only: ~w(assets fonts images favicon.ico robots.txt)
# Without "only:", all files in priv/static/ are served — robots.txt just works.Dynamic robots.txt via controller
For environment-based rules (e.g., block all crawlers in staging), serve robots.txt from a controller instead of a static file. Remove the static file first to avoid conflicts.
# lib/my_app_web/controllers/robots_controller.ex
defmodule MyAppWeb.RobotsController do
use MyAppWeb, :controller
def index(conn, _params) do
rules =
if Application.get_env(:my_app, :env) == :prod do
"""
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: *
Allow: /
"""
else
# Block everything in staging/dev
"""
User-agent: *
Disallow: /
"""
end
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, rules)
end
end
# In router.ex:
# get "/robots.txt", RobotsController, :index2. Hard blocking with a custom Plug
A Plug is a module with two functions: init/1 (compile-time config) and call/2 (request-time logic). Insert it in endpoint.ex after Plug.Static so robots.txt is still served, but all other requests from AI bots get a 403.
lib/my_app_web/plugs/block_ai_bots.ex
defmodule MyAppWeb.Plugs.BlockAiBots do
@moduledoc "Blocks known AI training crawlers with a 403 response."
import Plug.Conn
@behaviour Plug
# Compiled once at compile time — not per-request
@blocked_ua_pattern ~r/GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-Web|anthropic-ai|Google-Extended|Bytespider|CCBot|PerplexityBot|Applebot-Extended|DuckAssistBot|cohere-ai|Meta-ExternalAgent|Diffbot|YouBot|Amazonbot/i
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
ua =
conn
|> get_req_header("user-agent")
|> List.first("")
if Regex.match?(@blocked_ua_pattern, ua) do
conn
|> send_resp(403, "Forbidden")
|> halt()
else
conn
end
end
endendpoint.ex — insert after Plug.Static
# lib/my_app_web/endpoint.ex
# ...existing plugs...
plug Plug.Static,
at: "/",
from: :my_app,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
# Bot blocking — AFTER Plug.Static so robots.txt is still served
plug MyAppWeb.Plugs.BlockAiBots
# ...rest of endpoint (Plug.RequestId, Plug.Telemetry, etc.)...
plug MyAppWeb.Routerhalt() is required
After send_resp/3, you must call halt() to stop the Plug pipeline. Without halt(), the connection continues through the router and handler — the 403 is sent but the page is also rendered, wasting resources.
Alternative: block in router.ex pipeline
If you only want to block bots from browser-facing routes (not API endpoints), add the plug to a router pipeline instead of the endpoint.
# lib/my_app_web/router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
# Block AI bots — only for browser routes
plug MyAppWeb.Plugs.BlockAiBots
end
# API routes are not affected:
pipeline :api do
plug :accepts, ["json"]
end3. noai meta tag in HEEx layouts
Phoenix server-renders every page on the initial request — including LiveView pages. The noai meta tag in your root layout is included in every HTML response that reaches the browser, so all crawlers see it.
root.html.heex — site-wide noai (Phoenix 1.7+)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<%!-- Opt out of AI training — all pages --%>
<meta
name="robots"
content={assigns[:robots_meta] || "noai, noimageai"}
/>
<.live_title><%= assigns[:page_title] || "My App" %></.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static src={~p"/assets/app.js"}></script>
</head>
<body>
<%= @inner_content %>
</body>
</html>Per-page override via assigns
# In a controller action — override the default noai for a specific page:
def public_post(conn, %{"slug" => slug}) do
post = Blog.get_post!(slug)
conn
|> assign(:robots_meta, "index, follow") # Allow AI for this page
|> assign(:page_title, post.title)
|> render(:show, post: post)
end
# In a LiveView mount — same approach:
def mount(_params, _session, socket) do
{:ok, assign(socket, robots_meta: "noai, noimageai", page_title: "Dashboard")}
endLiveView and meta tags
Phoenix LiveView server-renders the full HTML on the first request — so the noai meta tag in root.html.heex is visible to all crawlers. After the initial render, LiveView switches to WebSocket for navigation. AI bots do not follow WebSocket connections — they only see the first server-rendered response, which includes your meta tags.
4. X-Robots-Tag response header
Add a plug to your :browser pipeline that sets the header on every response. This works even when bots ignore the meta tag.
lib/my_app_web/plugs/x_robots_tag.ex
defmodule MyAppWeb.Plugs.XRobotsTag do
@moduledoc "Sets X-Robots-Tag header to opt out of AI training."
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
put_resp_header(conn, "x-robots-tag", "noai, noimageai")
end
end
# In router.ex:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.XRobotsTag # ← Add this
plug MyAppWeb.Plugs.BlockAiBots # ← And this
endThe X-Robots-Tag header is set before the bot-blocking plug runs. If the bot is blocked, the 403 response does not include the header (which is fine — a 403 already prevents content access).
5. Complete endpoint + router setup
Combining all layers: Plug.Static serves robots.txt first, the bot-blocking plug rejects known crawlers, the router pipeline adds X-Robots-Tag, and the layout includes the noai meta tag.
endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
@session_options [
store: :cookie,
key: "_my_app_key",
signing_salt: "your_salt",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
# 1. Static files — serves robots.txt before anything else
plug Plug.Static,
at: "/",
from: :my_app,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
# 2. Bot blocking — after static files so robots.txt is accessible
plug MyAppWeb.Plugs.BlockAiBots
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug MyAppWeb.Router
endrouter.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.XRobotsTag
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
live "/dashboard", DashboardLive
end
scope "/api", MyAppWeb do
pipe_through :api
# API routes — no bot blocking, no X-Robots-Tag
end
end6. nginx — block at the proxy layer
For Phoenix deployed behind nginx (common with Elixir releases and Docker), block AI bots at the proxy before they reach the BEAM VM. This is the most efficient approach — blocked requests never consume Erlang process resources.
# /etc/nginx/conf.d/phoenix-app.conf
map $http_user_agent $blocked_bot {
default 0;
"~*GPTBot" 1;
"~*ChatGPT-User" 1;
"~*OAI-SearchBot" 1;
"~*ClaudeBot" 1;
"~*anthropic-ai" 1;
"~*Google-Extended" 1;
"~*Bytespider" 1;
"~*CCBot" 1;
"~*PerplexityBot" 1;
"~*Applebot-Extended" 1;
}
upstream phoenix {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name example.com;
# Always serve robots.txt
location = /robots.txt {
proxy_pass http://phoenix;
}
location / {
if ($blocked_bot) {
return 403;
}
proxy_pass http://phoenix;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for LiveView
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}7. Deployment comparison
| Platform | Bot blocking | X-Robots-Tag | Notes |
|---|---|---|---|
| Fly.io | Custom Plug (app-level) | Plug header | fly.toml auto_stop; releases with mix release |
| Gigalixir | Custom Plug (app-level) | Plug header | Managed Elixir PaaS; git push deploys |
| Docker + nginx | nginx map + Plug fallback | nginx add_header | Most efficient; blocks before BEAM VM |
| Docker (standalone) | Custom Plug (app-level) | Plug header | mix release; distroless or alpine image |
| Render | Custom Plug (app-level) | Plug header | Docker or native Elixir buildpack |
| AWS ECS / Fargate | ALB rules + Plug | Plug header | ALB can inspect UA before container |
| VPS + nginx | nginx map block | nginx add_header | Full control; systemd for releases |
8. Docker with Elixir releases
Phoenix uses mix release to produce a self-contained Erlang release. The priv/static/ directory (including robots.txt) is bundled into the release automatically.
# Dockerfile — multi-stage Phoenix release
FROM elixir:1.16-alpine AS build
RUN apk add --no-cache build-base git
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY config config
COPY lib lib
COPY priv priv
COPY assets assets
RUN mix assets.deploy
RUN mix compile
RUN mix release
# ── Runtime ──────────────────────────────────────────────────────────────
FROM alpine:3.19 AS runtime
RUN apk add --no-cache libstdc++ openssl ncurses-libs
WORKDIR /app
COPY --from=build /app/_build/prod/rel/my_app ./
ENV PHX_HOST=example.com
ENV PORT=4000
EXPOSE 4000
CMD ["bin/my_app", "start"]priv/static/robots.txt is included in the release via mix assets.deploy. Verify with: ls _build/prod/rel/my_app/lib/my_app-0.1.0/priv/static/robots.txt
FAQ
How do I serve robots.txt in Phoenix?
Place it in priv/static/robots.txt. Plug.Static (configured in endpoint.ex) serves files from priv/static/ at the root path. If your Plug.Static uses the only: option to whitelist files, add "robots.txt" to that list — otherwise the file is silently ignored and returns 404.
Where should I add bot-blocking middleware in Phoenix?
Add a custom Plug in endpoint.ex AFTER Plug.Static but BEFORE your router. This ensures robots.txt is served (Plug.Static handles it first) while all other requests from AI bots get a 403. The Plug must call halt() after send_resp/3 to stop the pipeline — without halt(), the request continues to the router.
Do AI bots see noai meta tags on LiveView pages?
Yes, on the initial request. LiveView server-renders the full HTML on first load — the noai meta tag in root.html.heex is in that response. After the initial render, LiveView switches to WebSocket for navigation, but AI crawlers don't follow WebSocket connections. They only see the first server-rendered HTML, which includes your meta tags.
What is the difference between blocking in endpoint.ex vs router.ex?
endpoint.ex runs for every HTTP request before routing — blocking here catches all requests. router.ex pipelines only run for matched routes — blocking here is more targeted but misses unmatched paths and non-browser routes. For bot blocking, endpoint.ex is the right place.
How do I add X-Robots-Tag headers?
Create a Plug that calls put_resp_header(conn, "x-robots-tag", "noai, noimageai") and add it to your :browser pipeline in router.ex. This applies the header to all browser-facing routes without affecting API endpoints.
Why use @blocked_ua_pattern as a module attribute?
Module attributes prefixed with @ in Elixir are evaluated at compile time and inlined. The regex ~r/.../ is compiled once when the module is compiled, not on every request. This is the Elixir equivalent of module-scope constants in other languages — zero per-request overhead for pattern compilation.
Is your site protected from AI bots?
Run a free scan to check your robots.txt, meta tags, and overall AI readiness score.