Building a Real-Time Chat Application with Elixir, Phoenix, and Cloudflare: A Complete Guide
Building a real-time chat application requires careful consideration of infrastructure, scalability, and user experience. With Cloudflareβs new Containers platform (launched in June 2025) and D1 database, you can now deploy Elixir/Phoenix applications globally with edge-first architecture.
In this comprehensive guide, weβll build a production-ready chat application that leverages the best of both worlds: Elixirβs legendary concurrency capabilities and Cloudflareβs global edge network.
Why This Stack?
Elixir + Phoenix: Built for Real-Time
Elixir, running on the BEAM VM, was designed for distributed, fault-tolerant, real-time systems:
- Millions of concurrent connections on a single server
- Sub-millisecond message latency via lightweight processes
- Phoenix Channels for WebSocket communication
- Built-in clustering for distributed deployments
- Fault tolerance with automatic recovery
Real-world proof: Discord handles 5+ million concurrent users with Elixir, and WhatsApp (built on Erlang, Elixirβs foundation) supported 900 million users with just 50 engineers.
Cloudflare Containers: Edge-First Deployment
Cloudflare Containers, launched in public beta in June 2025, brings containerized applications to the edge:
- Global deployment across 330+ cities in 120+ countries
- Docker-compatible - deploy standard containers
- Scale to zero pricing - pay only for active compute time (billed every 10ms)
- Low latency - containers run close to your users
- Simple deployment -
wrangler deploy pushes your Docker image globally
Cloudflare D1: Serverless SQLite at the Edge
D1 is Cloudflareβs distributed SQLite database:
- Global read replication (as of May 2025)
- Built-in disaster recovery
- SQLite semantics - familiar SQL interface
- Jurisdictional control - specify data residency (EU, FedRAMP)
- HTTP and Worker API access
Architecture Overview
Our chat application architecture leverages Cloudflareβs edge infrastructure:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Cloudflare Global Network β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Phoenix Container (Cloudflare Containers) β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β Phoenix Channels (WebSocket) β β β
β β β - Room management β β β
β β β - Message broadcasting β β β
β β β - Presence tracking β β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β β β Phoenix LiveView β β β
β β β - Chat UI β β β
β β β - Real-time updates β β β
β β ββββββββββββββββββββββββββββββββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Cloudflare D1 Database (SQLite) β β
β β - Users β β
β β - Rooms β β
β β - Messages (persisted chat history) β β
β β - Global read replication β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
End Users Worldwide
Key Design Decisions:
- Phoenix Container on Cloudflare: Handles WebSocket connections and real-time message routing
- D1 for Persistence: Stores user data, room metadata, and message history
- Phoenix Channels: Manages real-time bidirectional communication
- Phoenix LiveView: Provides reactive UI without complex JavaScript
- Global Edge Deployment: Low latency for users worldwide
Prerequisites
Before we start, ensure you have:
# Elixir 1.15+ and Erlang/OTP 26+
elixir --version
# Phoenix 1.7+
mix archive.install hex phx_new
# Docker (required for Cloudflare Containers)
docker --version
# Wrangler CLI (Cloudflare's deployment tool)
npm install -g wrangler
# Cloudflare account (free tier works)
wrangler login
Step 1: Create the Phoenix Application
Letβs start by generating a new Phoenix application optimized for real-time features:
# Generate Phoenix app with LiveView
mix phx.new chat_app --live
cd chat_app
# Install dependencies
mix deps.get
# Create database (we'll migrate to D1 later)
mix ecto.create
Update config/runtime.exs for Cloudflare Containers:
# config/runtime.exs
import Config
if config_env() == :prod do
# Get D1 database URL from environment
database_url = System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
config :chat_app, ChatApp.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# Important for D1's SQLite backend
adapter: Ecto.Adapters.SQLite3
# Configure endpoint for Cloudflare
host = System.get_env("PHX_HOST") || "chat.example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :chat_app, ChatAppWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
ip: {0, 0, 0, 0},
port: port
],
check_origin: false, # Cloudflare handles this
server: true,
secret_key_base: System.get_env("SECRET_KEY_BASE")
end
Step 2: Set Up the Database Schema
Create the necessary schemas for our chat application:
# Generate User schema
mix phx.gen.schema Accounts.User users \
username:string \
email:string \
password_hash:string
# Generate Room schema
mix phx.gen.schema Chat.Room rooms \
name:string \
description:text \
slug:string:unique
# Generate Message schema
mix phx.gen.schema Chat.Message messages \
content:text \
user_id:references:users \
room_id:references:rooms
Update the migrations to add indexes and constraints:
# priv/repo/migrations/TIMESTAMP_create_users.exs
defmodule ChatApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string, null: false
add :email, :string, null: false
add :password_hash, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
create unique_index(:users, [:username])
end
end
# priv/repo/migrations/TIMESTAMP_create_rooms.exs
defmodule ChatApp.Repo.Migrations.CreateRooms do
use Ecto.Migration
def change do
create table(:rooms) do
add :name, :string, null: false
add :description, :text
add :slug, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:rooms, [:slug])
end
end
# priv/repo/migrations/TIMESTAMP_create_messages.exs
defmodule ChatApp.Repo.Migrations.CreateMessages do
use Ecto.Migration
def change do
create table(:messages) do
add :content, :text, null: false
add :user_id, references(:users, on_delete: :delete_all), null: false
add :room_id, references(:rooms, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create index(:messages, [:user_id])
create index(:messages, [:room_id])
create index(:messages, [:inserted_at])
end
end
Run migrations:
mix ecto.migrate
Step 3: Implement Phoenix Channels for Real-Time Chat
Phoenix Channels provide the WebSocket infrastructure for real-time messaging:
Create the Room Channel
# lib/chat_app_web/channels/room_channel.ex
defmodule ChatAppWeb.RoomChannel do
use ChatAppWeb, :channel
alias ChatApp.Chat
alias ChatAppWeb.Presence
@impl true
def join("room:" <> room_slug, _params, socket) do
# Verify user is authenticated
case socket.assigns[:user_id] do
nil ->
{:error, %{reason: "unauthorized"}}
user_id ->
# Load room
case Chat.get_room_by_slug(room_slug) do
nil ->
{:error, %{reason: "room not found"}}
room ->
# Send presence tracking after join
send(self(), :after_join)
# Load recent messages (last 50)
messages = Chat.list_recent_messages(room.id, limit: 50)
{:ok,
%{messages: format_messages(messages)},
assign(socket, :room_id, room.id)}
end
end
end
@impl true
def handle_in("new_message", %{"content" => content}, socket) do
user_id = socket.assigns.user_id
room_id = socket.assigns.room_id
# Create and persist message
case Chat.create_message(%{
content: content,
user_id: user_id,
room_id: room_id
}) do
{:ok, message} ->
# Broadcast to all users in the room
broadcast!(socket, "new_message", %{
id: message.id,
content: message.content,
user: %{
id: message.user.id,
username: message.user.username
},
inserted_at: message.inserted_at
})
{:reply, {:ok, %{message_id: message.id}}, socket}
{:error, changeset} ->
{:reply, {:error, %{errors: changeset}}, socket}
end
end
@impl true
def handle_in("typing", _params, socket) do
user_id = socket.assigns.user_id
# Broadcast typing indicator to others (not self)
broadcast_from!(socket, "user_typing", %{
user_id: user_id
})
{:noreply, socket}
end
@impl true
def handle_info(:after_join, socket) do
user_id = socket.assigns.user_id
# Track user presence in this room
{:ok, _} =
Presence.track(socket, user_id, %{
online_at: System.system_time(:second),
user_id: user_id
})
# Push current presence list to user
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
# Helper function to format messages
defp format_messages(messages) do
Enum.map(messages, fn msg ->
%{
id: msg.id,
content: msg.content,
user: %{
id: msg.user.id,
username: msg.user.username
},
inserted_at: msg.inserted_at
}
end)
end
end
Set Up Presence Tracking
# lib/chat_app_web/channels/presence.ex
defmodule ChatAppWeb.Presence do
@moduledoc """
Provides presence tracking to channels and processes.
See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
docs for more details.
"""
use Phoenix.Presence,
otp_app: :chat_app,
pubsub_server: ChatApp.PubSub
end
# lib/chat_app_web/channels/user_socket.ex
defmodule ChatAppWeb.UserSocket do
use Phoenix.Socket
# Channels
channel "room:*", ChatAppWeb.RoomChannel
@impl true
def connect(%{"token" => token}, socket, _connect_info) do
# Verify token and authenticate user
case ChatAppWeb.Auth.verify_token(token) do
{:ok, user_id} ->
{:ok, assign(socket, :user_id, user_id)}
{:error, _reason} ->
:error
end
end
def connect(_params, _socket, _connect_info), do: :error
@impl true
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
end
Update lib/chat_app_web/endpoint.ex to include the socket:
# lib/chat_app_web/endpoint.ex
defmodule ChatAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :chat_app
# Socket configuration
socket "/socket", ChatAppWeb.UserSocket,
websocket: [
timeout: 45_000,
transport_log: false
],
longpoll: false
# ... rest of endpoint configuration
end
Step 4: Build the Chat UI with LiveView
Phoenix LiveView provides a reactive UI without complex JavaScript:
# lib/chat_app_web/live/chat_live.ex
defmodule ChatAppWeb.ChatLive do
use ChatAppWeb, :live_view
alias ChatApp.Chat
alias Phoenix.Socket.Broadcast
@impl true
def mount(%{"slug" => room_slug}, session, socket) do
# Authenticate user from session
user_id = session["user_id"]
if user_id && connected?(socket) do
# Load room
room = Chat.get_room_by_slug!(room_slug)
# Subscribe to room updates via PubSub
Phoenix.PubSub.subscribe(ChatApp.PubSub, "room:#{room.id}")
# Load recent messages
messages = Chat.list_recent_messages(room.id, limit: 50)
{:ok,
assign(socket,
room: room,
messages: messages,
user_id: user_id,
typing_users: [],
new_message: ""
)}
else
# Redirect to login if not authenticated
{:ok,
socket
|> put_flash(:error, "You must log in to access this page.")
|> redirect(to: "/login")}
end
end
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col h-screen max-w-4xl mx-auto">
<!-- Room Header -->
<div class="bg-blue-600 text-white p-4 shadow-lg">
<h1 class="text-2xl font-bold"><%= @room.name %></h1>
<p class="text-sm"><%= @room.description %></p>
</div>
<!-- Messages Container -->
<div
id="messages-container"
class="flex-1 overflow-y-auto p-4 bg-gray-50"
phx-hook="ScrollToBottom"
>
<%= for message <- @messages do %>
<div class="mb-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">
<%= String.first(message.user.username) %>
</div>
</div>
<div class="ml-3 flex-1">
<div class="flex items-baseline">
<span class="font-semibold text-gray-900">
<%= message.user.username %>
</span>
<span class="ml-2 text-xs text-gray-500">
<%= format_timestamp(message.inserted_at) %>
</span>
</div>
<p class="mt-1 text-gray-800"><%= message.content %></p>
</div>
</div>
</div>
<% end %>
<!-- Typing Indicator -->
<%= if length(@typing_users) > 0 do %>
<div class="text-sm text-gray-500 italic">
<%= typing_indicator_text(@typing_users) %>
</div>
<% end %>
</div>
<!-- Message Input -->
<div class="border-t bg-white p-4">
<form phx-submit="send_message" class="flex gap-2">
<input
type="text"
name="message"
value={@new_message}
phx-change="typing"
placeholder="Type a message..."
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
autocomplete="off"
/>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Send
</button>
</form>
</div>
</div>
"""
end
@impl true
def handle_event("send_message", %{"message" => content}, socket) do
if String.trim(content) != "" do
# Create message
case Chat.create_message(%{
content: content,
user_id: socket.assigns.user_id,
room_id: socket.assigns.room.id
}) do
{:ok, message} ->
# Broadcast to all LiveView processes
Phoenix.PubSub.broadcast(
ChatApp.PubSub,
"room:#{socket.assigns.room.id}",
{:new_message, message}
)
{:noreply, assign(socket, :new_message, "")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to send message")}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("typing", _params, socket) do
# Broadcast typing event
Phoenix.PubSub.broadcast(
ChatApp.PubSub,
"room:#{socket.assigns.room.id}",
{:user_typing, socket.assigns.user_id}
)
{:noreply, socket}
end
@impl true
def handle_info({:new_message, message}, socket) do
# Append new message to the list
{:noreply, update(socket, :messages, fn messages -> messages ++ [message] end)}
end
@impl true
def handle_info({:user_typing, user_id}, socket) do
# Add user to typing list (with debounce in production)
if user_id != socket.assigns.user_id do
{:noreply, update(socket, :typing_users, fn users -> [user_id | users] end)}
else
{:noreply, socket}
end
end
# Helper functions
defp format_timestamp(datetime) do
Calendar.strftime(datetime, "%I:%M %p")
end
defp typing_indicator_text([user_id]) do
"#{user_id} is typing..."
end
defp typing_indicator_text(user_ids) do
"#{length(user_ids)} people are typing..."
end
end
Step 5: Dockerize the Phoenix Application
Create a production-optimized Dockerfile for Cloudflare Containers:
# Dockerfile
# Build stage
FROM hexpm/elixir:1.15.7-erlang-26.1.2-alpine-3.18.4 AS build
# Install build dependencies
RUN apk add --no-cache build-base git nodejs npm
# Set working directory
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
# Set build ENV
ENV MIX_ENV=prod
# Install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
# Copy application files
COPY config config
COPY priv priv
COPY lib lib
COPY assets assets
# Compile assets
RUN cd assets && npm install && npm run deploy
RUN mix phx.digest
# Compile application
RUN mix compile
# Build release
RUN mix release
# Runtime stage
FROM alpine:3.18.4
# Install runtime dependencies
RUN apk add --no-cache openssl ncurses-libs libstdc++ libgcc
# Create app user
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app app
WORKDIR /app
# Copy release from build stage
COPY --from=build --chown=app:app /app/_build/prod/rel/chat_app ./
USER app
# Expose port
EXPOSE 4000
# Set environment
ENV HOME=/app
ENV MIX_ENV=prod
# Start the application
CMD ["bin/chat_app", "start"]
Create .dockerignore:
# .dockerignore
_build/
deps/
.git/
.gitignore
node_modules/
assets/node_modules/
priv/static/
.elixir_ls/
Build and test locally:
# Build Docker image
docker build -t chat-app:latest .
# Run locally
docker run -p 4000:4000 \
-e SECRET_KEY_BASE="$(mix phx.gen.secret)" \
-e DATABASE_URL="ecto://user:pass@localhost/chat_app" \
chat-app:latest
Create and configure your D1 database:
# Create D1 database
wrangler d1 create chat-app-db
# This outputs your database ID and configuration
# Add to wrangler.toml (we'll create this next)
Create D1 migration files:
# Create migrations directory
mkdir -p cloudflare/migrations
# Create initial migration
cat > cloudflare/migrations/0001_initial_schema.sql << 'EOF'
-- Create users table
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
-- Create rooms table
CREATE TABLE rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
slug TEXT NOT NULL UNIQUE,
inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_rooms_slug ON rooms(slug);
-- Create messages table
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
user_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE INDEX idx_messages_user_id ON messages(user_id);
CREATE INDEX idx_messages_room_id ON messages(room_id);
CREATE INDEX idx_messages_inserted_at ON messages(inserted_at);
-- Insert default room
INSERT INTO rooms (name, description, slug) VALUES
('General', 'General discussion room', 'general'),
('Tech Talk', 'Discuss technology and programming', 'tech-talk'),
('Random', 'Random conversations', 'random');
EOF
Apply migrations:
# Apply migration to D1
wrangler d1 execute chat-app-db --file=cloudflare/migrations/0001_initial_schema.sql
Create wrangler.toml for Cloudflare deployment:
# wrangler.toml
name = "chat-app"
main = "build/worker.js"
compatibility_date = "2025-11-01"
# Container configuration
[containers]
image = "chat-app:latest"
# Instance size (dev, basic, or standard)
[containers.resources]
type = "standard" # 4GB RAM, 0.5 vCPU
# type = "basic" # 1GB RAM, 0.25 vCPU
# type = "dev" # 256MB RAM, 0.0625 vCPU
# Environment variables
[env.production]
vars = { PHX_HOST = "chat.yourdomain.com" }
# D1 Database binding
[[d1_databases]]
binding = "DB"
database_name = "chat-app-db"
database_id = "your-database-id-here" # From wrangler d1 create output
# KV for session storage (optional)
[[kv_namespaces]]
binding = "SESSIONS"
id = "your-kv-namespace-id"
Since D1 uses SQLite, update your dependencies:
# mix.exs
defp deps do
[
# ... existing deps
{:ecto_sql, "~> 3.10"},
{:ecto_sqlite3, "~> 0.12"}, # SQLite adapter for D1
# ... rest of deps
]
end
Update Repo configuration:
# config/runtime.exs
config :chat_app, ChatApp.Repo,
database: System.get_env("D1_DATABASE_PATH") || "priv/chat_app.db",
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
Step 8: Deploy to Cloudflare
Deploy your containerized Phoenix app to Cloudflareβs global network:
# Login to Cloudflare
wrangler login
# Build Docker image
docker build -t chat-app:latest .
# Deploy to Cloudflare Containers
wrangler deploy
# Output will show:
# β¨ Successfully deployed container to Cloudflare
# π Available at: https://chat-app.your-subdomain.workers.dev
Configure Custom Domain
# Add custom domain via Cloudflare dashboard or CLI
wrangler domains add chat.yourdomain.com
# Update DNS records in Cloudflare dashboard
# Add CNAME: chat -> chat-app.your-subdomain.workers.dev
Step 9: Monitoring and Scaling
Add Logging
# lib/chat_app_web/telemetry.ex
defmodule ChatAppWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
def init(_arg) do
children = [
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
# Channel Metrics
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
counter("phoenix.channel_joined.count"),
# Database Metrics
summary("chat_app.repo.query.total_time",
unit: {:native, :millisecond}
),
counter("chat_app.repo.query.count"),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
{ChatAppWeb.Telemetry, :measure_active_connections, []}
]
end
def measure_active_connections do
# Custom metric for active WebSocket connections
active_connections = Phoenix.PubSub.node_name(ChatApp.PubSub)
:telemetry.execute([:chat_app, :connections], %{count: active_connections}, %{})
end
end
Cloudflare automatically provides metrics for your containers:
- Request volume: Requests per second
- Response times: P50, P95, P99 latencies
- Error rates: 4xx and 5xx responses
- Resource usage: CPU and memory utilization
- Geographic distribution: Where your users are connecting from
Access via Cloudflare Dashboard β Workers & Pages β Your Container β Analytics
Set Up Alerts
# Create alert for high error rate
wrangler alert create \
--name "High Error Rate" \
--type error-rate \
--threshold 5 \
--notification-email your@email.com
1. Connection Pooling
Optimize database connections for D1:
# config/runtime.exs
config :chat_app, ChatApp.Repo,
pool_size: 5, # Lower for edge deployment
queue_target: 50,
queue_interval: 1000
2. Message Batching
For high-volume rooms, batch messages:
# lib/chat_app_web/channels/room_channel.ex
defmodule ChatAppWeb.RoomChannel do
# ... existing code
@batch_interval 100 # milliseconds
@batch_size 10
def handle_in("new_message", %{"content" => content}, socket) do
# Queue message for batching
queue_message(socket, content)
{:noreply, socket}
end
defp queue_message(socket, content) do
# Implement message batching logic
# Send multiple messages in a single database transaction
end
end
3. Presence Optimization
Optimize presence tracking for large rooms:
# lib/chat_app_web/channels/room_channel.ex
def handle_info(:after_join, socket) do
# Only track presence for rooms with < 1000 users
room_size = get_room_size(socket)
if room_size < 1000 do
Presence.track(socket, socket.assigns.user_id, %{
online_at: System.system_time(:second)
})
end
{:noreply, socket}
end
4. Caching
Leverage Cloudflareβs KV for caching:
defmodule ChatApp.Cache do
@kv_binding Application.compile_env(:chat_app, :kv_binding, "SESSIONS")
def get_room(slug) do
# Check cache first
case Cloudflare.KV.get(@kv_binding, "room:#{slug}") do
{:ok, cached_room} ->
Jason.decode!(cached_room)
{:error, :not_found} ->
# Load from D1 and cache
room = ChatApp.Chat.get_room_by_slug!(slug)
cache_room(slug, room)
room
end
end
defp cache_room(slug, room) do
Cloudflare.KV.put(
@kv_binding,
"room:#{slug}",
Jason.encode!(room),
expiration_ttl: 3600 # 1 hour
)
end
end
Security Best Practices
1. Rate Limiting
Implement rate limiting for message sending:
defmodule ChatAppWeb.RoomChannel do
@rate_limit_window 10_000 # 10 seconds
@max_messages_per_window 10
def handle_in("new_message", params, socket) do
case check_rate_limit(socket) do
:ok ->
create_and_broadcast_message(params, socket)
{:error, :rate_limited} ->
{:reply, {:error, %{reason: "Too many messages. Please slow down."}}, socket}
end
end
defp check_rate_limit(socket) do
now = System.system_time(:millisecond)
recent_messages = socket.assigns[:recent_message_times] || []
# Filter messages within the window
messages_in_window =
Enum.filter(recent_messages, fn ts ->
now - ts < @rate_limit_window
end)
if length(messages_in_window) < @max_messages_per_window do
# Update socket with new timestamp
updated_times = [now | messages_in_window]
{:ok, assign(socket, :recent_message_times, updated_times)}
else
{:error, :rate_limited}
end
end
end
Sanitize user input to prevent XSS:
defmodule ChatApp.Chat do
def create_message(attrs) do
%Message{}
|> Message.changeset(sanitize_content(attrs))
|> Repo.insert()
end
defp sanitize_content(%{"content" => content} = attrs) do
# Remove HTML tags and sanitize
clean_content =
content
|> HtmlSanitizeEx.strip_tags()
|> String.trim()
|> String.slice(0, 2000) # Max 2000 characters
%{attrs | "content" => clean_content}
end
end
3. Authentication
Implement secure token-based authentication:
defmodule ChatAppWeb.Auth do
@secret_key Application.compile_env(:chat_app, :secret_key_base)
@max_age 86400 # 24 hours
def generate_token(user_id) do
Phoenix.Token.sign(@secret_key, "user auth", user_id)
end
def verify_token(token) do
case Phoenix.Token.verify(@secret_key, "user auth", token, max_age: @max_age) do
{:ok, user_id} -> {:ok, user_id}
{:error, _} -> {:error, :invalid_token}
end
end
end
Cost Optimization
Cloudflare Containers pricing (scale-to-zero):
Instance Types:
- Dev (256MB, 0.0625 vCPU): $0.000015/ms
- Basic (1GB, 0.25 vCPU): $0.00006/ms
- Standard (4GB, 0.5 vCPU): $0.00024/ms
Example: Chat app with 1000 concurrent users
- Average session: 30 minutes
- Messages: 10/minute
- Basic instance, 50% CPU utilization
Monthly cost calculation:
- Active time: 1000 users Γ 30 min Γ 30 days = 900,000 minutes
- Convert to milliseconds: 54,000,000,000 ms
- Cost: 54,000,000,000 Γ $0.00006 = $3,240/month
Compare to traditional hosting:
- AWS EC2 t3.large (2 vCPU, 8GB): ~$60/month Γ 3 instances = $180
- Plus RDS database: ~$100/month
- Plus Load Balancer: ~$20/month
- Total: ~$300/month (but limited to one region)
Cloudflare advantages:
β Global edge deployment (330+ locations)
β No load balancer costs
β Built-in DDoS protection
β Automatic scaling
β Pay only for active compute
Cost optimization tips:
- Use smaller instance sizes for low-traffic periods
- Implement connection pooling to reduce database costs
- Cache frequently accessed data in KV (free tier: 100K reads/day)
- Batch database operations to reduce D1 query costs
Troubleshooting Common Issues
Issue 1: WebSocket Connection Failures
# Ensure endpoint is configured for WebSocket
# config/runtime.exs
config :chat_app, ChatAppWeb.Endpoint,
http: [
protocol_options: [
idle_timeout: 60_000, # 60 seconds
websocket_timeout: 60_000
]
]
Issue 2: D1 Connection Errors
# Check D1 database status
wrangler d1 info chat-app-db
# Test connection
wrangler d1 execute chat-app-db --command="SELECT 1"
# Check bindings in wrangler.toml
wrangler deployments list
Issue 3: Container Build Failures
# Check Docker build logs
docker build -t chat-app:latest . --progress=plain
# Verify Elixir/Erlang versions match
docker run -it chat-app:latest sh -c "elixir --version"
# Test locally before deploying
docker run -p 4000:4000 -e SECRET_KEY_BASE="test" chat-app:latest
Real-World Production Considerations
1. Multi-Region Deployment
Cloudflare automatically deploys your container globally, but you can optimize for specific regions:
# wrangler.toml
[placement]
# Hint for Cloudflare to prioritize these regions
mode = "smart"
2. Database Jurisdictions
For compliance (GDPR, etc.), specify data residency:
# Create EU-only database
wrangler d1 create chat-app-db --jurisdiction=eu
# Or FedRAMP for US government compliance
wrangler d1 create chat-app-db --jurisdiction=fedramp
3. Horizontal Scaling
Phoenix clustering for distributed deployment:
# config/runtime.exs
config :chat_app, ChatApp.Repo,
# Use libcluster for automatic clustering
topologies: [
cloudflare: [
strategy: Elixir.Cluster.Strategy.Gossip,
config: [
port: 45892,
if_addr: "0.0.0.0",
multicast_addr: "230.1.1.251",
multicast_ttl: 1,
secret: System.get_env("CLUSTER_SECRET")
]
]
]
Conclusion
Building a real-time chat application with Elixir, Phoenix, and Cloudflare Containers combines the best of both worlds:
β
Elixir/Phoenix: Legendary concurrency, built-in WebSocket support, fault tolerance
β
Cloudflare Containers: Global edge deployment, scale-to-zero pricing, simple Docker workflow
β
Cloudflare D1: Distributed SQLite, global replication, serverless architecture
Key Takeaways
- Phoenix Channels provide robust WebSocket infrastructure for real-time messaging
- Cloudflare Containers make global deployment as simple as
wrangler deploy
- D1 Database offers SQLite familiarity with edge distribution
- Cost-effective scaling with pay-per-use pricing
- Production-ready with built-in monitoring, DDoS protection, and global CDN
When This Stack Excels
β
Perfect for:
- Real-time chat applications
- Collaborative tools
- Live dashboards
- Global user base
- Unpredictable traffic patterns
- Startups optimizing costs
β οΈ Consider alternatives for:
- Heavy relational database workloads (use Cloudflare with external DB)
- Extremely large files/media (add R2 for object storage)
- Legacy system integrations
Next Steps
- Clone the starter repo (link to GitHub repository)
- Customize the UI with your branding
- Add features: File uploads, emoji reactions, threads
- Integrate authentication: OAuth, SSO, multi-factor
- Monitor and optimize: Use Cloudflare analytics
- Scale globally: Let Cloudflare handle the infrastructure
The future of real-time applications is edge-first, and with Elixir + Phoenix + Cloudflare, you have the perfect stack to build fast, reliable, globally distributed chat applications.
Ready to build your real-time application? Contact Async Squad Labs for expert help architecting and deploying production-grade Elixir/Phoenix applications on Cloudflareβs global network.
Related Articles
Our team of experienced software engineers specializes in building scalable applications with Elixir, Python, Go, and modern AI technologies. We help companies ship better software faster.
π¬ Stay Updated with Our Latest Insights
Get expert tips on software development, AI integration, and best practices delivered to your inbox. Join our community of developers and tech leaders.