Website Backend Development with Elixir (Phoenix)
Phoenix is a framework on Elixir, built on top of BEAM (Erlang VM). BEAM was originally created for telecommunications with nine-nines uptime requirements. This defines the platform's character: millions of lightweight processes isolated from each other, built-in failure recovery mechanisms, hot code reloading in production.
Where this makes sense
Chats, real-time notification systems, IoT backends, game servers, financial systems with uptime requirements — BEAM is in its element. WhatsApp held 900 million users with 50 engineers, largely thanks to Erlang. Phoenix adds convenient web layer with channels, LiveView, and Ecto.
Project structure
mix phx.new my_app --no-html --database postgres
cd my_app
mix deps.get
mix ecto.setup
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.AuthPipeline
end
scope "/api/v1", MyAppWeb do
pipe_through :api
resources "/users", UserController, only: [:index, :show, :create, :update, :delete]
resources "/orders", OrderController, except: [:new, :edit]
post "/auth/login", AuthController, :login
post "/auth/refresh", AuthController, :refresh
end
end
Ecto — schemas and queries
# lib/my_app/accounts/user.ex
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :name, :string
field :password, :string, virtual: true
field :password_hash, :string
field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
has_many :orders, MyApp.Orders.Order
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :password])
|> validate_required([:email, :name, :password])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "invalid format")
|> validate_length(:password, min: 8)
|> unique_constraint(:email)
|> put_password_hash()
end
defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pw}} = cs) do
change(cs, Argon2.add_hash(pw))
end
defp put_password_hash(cs), do: cs
end
# lib/my_app/orders.ex — context
defmodule MyApp.Orders do
import Ecto.Query
alias MyApp.{Repo, Orders.Order}
def list_for_user(user_id, opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 25)
Order
|> where(user_id: ^user_id)
|> order_by(desc: :inserted_at)
|> preload(:items)
|> Repo.paginate(page: page, page_size: per_page)
end
def create_order(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{user_id: user.id}, attrs))
|> Ecto.Multi.run(:payment, fn _repo, %{order: order} ->
MyApp.Payments.charge(order)
end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} -> {:ok, order}
{:error, :order, changeset, _} -> {:error, changeset}
{:error, :payment, reason, _} -> {:error, reason}
end
end
end
Phoenix Channels — WebSocket real-time
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
alias MyApp.Messages
def join("room:" <> room_id, _params, socket) do
if authorized?(socket, room_id) do
{:ok, assign(socket, :room_id, room_id)}
else
{:error, %{reason: "unauthorized"}}
end
end
def handle_in("new_message", %{"body" => body}, socket) do
case Messages.create(socket.assigns.current_user, socket.assigns.room_id, body) do
{:ok, message} ->
broadcast!(socket, "new_message", %{
id: message.id,
body: message.body,
user: message.user.name,
inserted_at: message.inserted_at
})
{:noreply, socket}
{:error, _changeset} ->
{:reply, {:error, %{reason: "invalid message"}}, socket}
end
end
defp authorized?(socket, room_id) do
# check user permissions on room
MyApp.Rooms.member?(room_id, socket.assigns.current_user.id)
end
end
GenServer for state
# lib/my_app/rate_limiter.ex
defmodule MyApp.RateLimiter do
use GenServer
@window_ms 60_000
@max_requests 100
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def check(key) do
GenServer.call(__MODULE__, {:check, key})
end
def init(state), do: {:ok, state}
def handle_call({:check, key}, _from, state) do
now = System.monotonic_time(:millisecond)
window_start = now - @window_ms
requests = Map.get(state, key, [])
recent = Enum.filter(requests, &(&1 > window_start))
if length(recent) >= @max_requests do
{:reply, {:error, :rate_limited}, Map.put(state, key, recent)}
else
{:reply, :ok, Map.put(state, key, [now | recent])}
end
end
end
Supervisor tree
# lib/my_app/application.ex
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
MyAppWeb.Telemetry,
{Phoenix.PubSub, name: MyApp.PubSub},
MyApp.RateLimiter,
{MyApp.Workers.EmailWorker, []},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
If EmailWorker crashes — Supervisor restarts it automatically. Other processes are unaffected.
Deployment via Mix Releases
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
Dockerfile:
FROM elixir:1.16-otp-26 AS builder
WORKDIR /app
ENV MIX_ENV=prod
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
COPY . .
RUN mix compile
RUN mix release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y openssl libncurses5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/_build/prod/rel/my_app ./
CMD ["bin/my_app", "start"]
Development timeline
Phoenix is fast in development with Elixir experience. If new to language — budget 2 weeks for Ecto, OTP patterns, and channels. API with standard CRUD + WebSocket + background jobs: 3–4 weeks. High-load system with clustering via libcluster and Horde: 5–8 weeks with load testing.







