Build Your Own Elixir Phoenix + LiveView: Step 10: Cowboy Adapter
An educational series based on first principles learning
What We’re Building
(Image generated from Gemini)
Our hand-built :gen_tcp server works, but it’s missing critical production features:
HTTP/2 support
SSL/TLS (HTTPS)
Keep-alive connections
Protection against malformed requests and slow-loris attacks
Connection pooling (multiple acceptors)
Instead of implementing all that ourselves (thousands of lines), we’ll use Cowboy — the same HTTP server Phoenix uses. We’ll write an adapter that translates between Cowboy and our %Ignite.Conn{}.
┌─ Before (Steps 1-9) ─────────────────────────────────────────┐
│ │
│ Browser ──▶ :gen_tcp (our server) ──▶ Parser ──▶ Router │
│ single accept loop │
│ no SSL, no HTTP/2 │
└──────────────────────────────────────────────────────────────┘
┌─ After (Step 10) ────────────────────────────────────────────┐
│ │
│ Browser ──▶ Cowboy ──▶ Adapter ──▶ Router │
│ 100+ acceptors translates │
│ SSL, HTTP/2 Cowboy req ←→ %Conn{} │
│ keep-alive │
└──────────────────────────────────────────────────────────────┘
Concepts You’ll Learn
Dependencies in Mix
Dependencies are declared in mix.exs.
Update mix.exs — add plug_cowboy to the deps/0 function:
defp deps do
[
{:plug_cowboy, "~> 2.7"}
]
end
Then install them:
mix deps.get
plug_cowboy pulls in Cowboy, Ranch (the TCP acceptor pool), and Plug.
The Adapter Pattern
An adapter translates between two interfaces that don’t know about each other:
Cowboy (speaks Cowboy requests) ←→ Adapter ←→ Ignite (speaks %Conn{})
Our framework doesn’t know Cowboy exists. Cowboy doesn’t know our framework exists. The adapter sits in the middle and translates.
┌───────────┐ ┌─────────────────┐ ┌───────────────┐
│ Cowboy │ │ Adapter │ │ Ignite │
│ ─┼────▶│ │────▶│ │
│ speaks │ │ Cowboy req ──▶ │ │ speaks │
│ Cowboy │◀────┤ %Conn{} ◀── │◀────┤ %Conn{} │
│ requests │ │ │ │ │
└───────────┘ └─────────────────┘ └───────────────┘
│
┌──────┴──────┐
│ Swappable! │
│ Bandit, │
│ another │
│ server... │
└─────────────┘
This means we could swap Cowboy for another server (like Bandit) by writing a different adapter — no framework code changes needed.
Cowboy Handler Behaviour
Cowboy calls our module’s init/2 for every request:
@behaviour :cowboy_handler
@impl true
def init(req, state) do
# req is a Cowboy request map
# We must return {:ok, req, state}
end
The req object contains method, path, headers, and functions to read the body.
:cowboy_router.compile
Cowboy has its own routing, but we don’t need it — we have our own router. We tell Cowboy to send ALL requests to our adapter:
:cowboy_router.compile([
{:_, [{"/[...]", Ignite.Adapters.Cowboy, []}]}
])
:_matches any hostname"/[...]"matches any pathEverything goes to
Ignite.Adapters.Cowboy
:cowboy_req Functions
Cowboy provides helper functions to work with requests:
:cowboy_req.has_body(req) # Does this request have a body?
:cowboy_req.read_body(req) # Read the body bytes
:cowboy_req.header("content-type", req, "") # Get a header
:cowboy_req.reply(200, headers, body, req) # Send response
@behaviour
@behaviour :cowboy_handler declares that this module implements Cowboy’s handler interface. It’s like implements in Java — the compiler checks that you define the required callbacks. @impl true (from Step 6) marks which functions satisfy the behaviour.
@behaviour :cowboy_handler
@impl true
def init(req, state) do # Required by :cowboy_handler
...
end
Guards (when)
Guards add extra conditions to pattern matching in function heads:
defp parse_body(body, _content_type) when byte_size(body) == 0 do
%{}
end
The when clause runs after the pattern matches but before the function body executes. Only a limited set of functions are allowed in guards (like byte_size/1, is_binary/1, >, ==).
Child Spec Maps
In Step 6, we used the shorthand {Ignite.Server, 4000} to tell the supervisor what to start. For Cowboy, we need the explicit map format:
%{
id: :cowboy_listener, # Unique name for the supervisor
start: {:cowboy, :start_clear, # {Module, Function, Args} — called to start the process
[:ignite_http, [port: 4000], %{env: %{dispatch: dispatch}}]}
}
The start: value is an MFA tuple (Module, Function, Arguments) — the supervisor calls apply(module, function, args) to start the child.
┌─ Supervision Tree ──────────────────────────────────┐
│ │
│ Ignite.Supervisor │
│ (one_for_one) │
│ │ │
│ ▼ │
│ :cowboy_listener │
│ ┌──────────────────────┐ │
│ │ Cowboy HTTP Server │ │
│ │ port: 4000 │ │
│ │ dispatch ──▶ Adapter │ │
│ └──────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ Ranch Acceptor Ranch Acceptor ... (100+) │
└─────────────────────────────────────────────────────┘
The Code
lib/ignite/adapters/cowboy.ex
Create lib/ignite/adapters/cowboy.ex:
The adapter does three things:
Convert Cowboy request →
%Ignite.Conn{}Route through
MyApp.Router.call/1Reply via
:cowboy_req.reply/4
defmodule Ignite.Adapters.Cowboy do
@moduledoc """
Bridges Cowboy's request format with Ignite's %Conn{} struct.
Cowboy calls `init/2` for every HTTP request. We convert the Cowboy
request into an %Ignite.Conn{}, run it through the router, and send
the response back through Cowboy.
"""
@behaviour :cowboy_handler
require Logger
@impl true
def init(req, state) do
conn = cowboy_to_conn(req)
conn = MyApp.Router.call(conn)
req = :cowboy_req.reply(
conn.status,
conn.resp_headers,
conn.resp_body,
req
)
{:ok, req, state}
end
defp cowboy_to_conn(req) do
# Read the body if present (POST/PUT/PATCH)
{body_params, _req} = read_cowboy_body(req)
# Convert Cowboy headers (list of tuples) to a map
headers =
req.headers
|> Enum.into(%{}, fn {k, v} -> {String.downcase(k), v} end)
%Ignite.Conn{
method: req.method,
path: req.path,
headers: headers,
params: body_params
}
end
defp read_cowboy_body(req) do
case :cowboy_req.has_body(req) do
true ->
{:ok, body, req} = :cowboy_req.read_body(req)
content_type = :cowboy_req.header("content-type", req, "")
{parse_body(body, content_type), req}
false ->
{%{}, req}
end
end
defp parse_body(body, "application/x-www-form-urlencoded" <> _) do
URI.decode_query(body)
end
defp parse_body(body, _) when byte_size(body) > 0 do
%{"_body" => body}
end
defp parse_body(_, _), do: %{}
end
Cowboy’s req is a map with keys like method, path, and headers. We read the body with :cowboy_req.read_body/1 and reuse the same parse_body logic from Step 9.
Updated lib/ignite/application.ex
Replace lib/ignite/application.ex with: the version below that starts Cowboy instead of our gen_tcp server:
defmodule Ignite.Application do
@moduledoc """
The OTP Application for Ignite.
Starts Cowboy as the HTTP server with our custom handler.
"""
use Application
require Logger
@impl true
def start(_type, _args) do
port = Application.get_env(:ignite, :port, 4000)
# Cowboy routing: send ALL requests to our adapter
dispatch =
:cowboy_router.compile([
{:_, [{"/[...]", Ignite.Adapters.Cowboy, []}]}
])
children = [
%{
id: :cowboy_listener,
start: {:cowboy, :start_clear, [
:ignite_http,
[port: port],
%{env: %{dispatch: dispatch}}
]}
}
]
Logger.info("Ignite is heating up on http://localhost:#{port}")
opts = [strategy: :one_for_one, name: Ignite.Supervisor]
Supervisor.start_link(children, opts)
end
end
What We Keep
Ignite.Serverstill exists as a reference for steps 1-9Ignite.Parseris no longer used (Cowboy parses for us)The Router, Controller, and Conn are unchanged — the adapter handles the translation
How It Works
Browser Cowboy Ignite
| | |
|--- HTTP request ------->| |
| | Acceptor pool (100+) |
| | Parse HTTP |
| | |
| |-- init(req, state) --->|
| | | cowboy_to_conn(req)
| | | Router.call(conn)
| | | Controller action
| |<- {:ok, req, state} ---|
| | |
|<-- HTTP response -------| |
Cowboy handles:
100+ acceptors (vs our single accept loop)
HTTP protocol compliance
Connection timeouts
Malformed request rejection
Try It Out
Install the dependency:
mix deps.getStart the server:
iex -S mixAll your routes still work:
http://localhost:4000/ → “Welcome to Ignite!”
http://localhost:4000/users/42 → User profile page
http://localhost:4000/hello → “Hello from the Controller!”
Test POST:
curl -X POST http://localhost:4000/users \
-d "username=jose"
The behavior is identical, but now you have a production-grade HTTP server underneath.
File Checklist
All files in the project after completing Step 10:
File → Statusmix.exs → Modified — added plug_cowboy dependencymix.lock → New — auto-generated by mix deps.getlib/ignite.ex → Unchangedlib/ignite/application.ex → Modified — starts Cowboy instead of Ignite.ServerUnchanged (kept as reference, no longer started)
lib/ignite/server.ex → lib/ignite/conn.ex → Unchangedlib/ignite/parser.ex → Unchanged (no longer used; Cowboy handles parsing)lib/ignite/router.exUnchangedlib/ignite/controller.exUnchangedlib/ignite/adapters/cowboy.ex → New — adapter translating Cowboy requests to Ignite.Conn{}Unchanged
lib/my_app/router.ex → lib/my_app/controllers/welcome_controller.ex → Unchangedlib/my_app/controllers/user_controller.ex → Unchangedtemplates/profile.html.eex → Unchanged
What’s Next
What happens when a controller crashes? Right now, Cowboy returns a generic error. In Step 11, we’ll add an Error Handler that catches exceptions and returns a friendly 500 page with a logged stacktrace.
← Previous: Step 9 - POST Body Parser | Next: Step 11 - Error Handler →


