1 min read

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:

  1. Phoenix Container on Cloudflare: Handles WebSocket connections and real-time message routing
  2. D1 for Persistence: Stores user data, room metadata, and message history
  3. Phoenix Channels: Manages real-time bidirectional communication
  4. Phoenix LiveView: Provides reactive UI without complex JavaScript
  5. 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

Configure for Production

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

Configure User Socket

# 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

Step 6: Configure Cloudflare D1 Database

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

Step 7: Configure Wrangler for Cloudflare Containers

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"

Configure Ecto Adapter for D1

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

Configure Cloudflare Analytics

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

Performance Optimization

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

2. Input Sanitization

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:

  1. Use smaller instance sizes for low-traffic periods
  2. Implement connection pooling to reduce database costs
  3. Cache frequently accessed data in KV (free tier: 100K reads/day)
  4. 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

  1. Phoenix Channels provide robust WebSocket infrastructure for real-time messaging
  2. Cloudflare Containers make global deployment as simple as wrangler deploy
  3. D1 Database offers SQLite familiarity with edge distribution
  4. Cost-effective scaling with pay-per-use pricing
  5. 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

  1. Clone the starter repo (link to GitHub repository)
  2. Customize the UI with your branding
  3. Add features: File uploads, emoji reactions, threads
  4. Integrate authentication: OAuth, SSO, multi-factor
  5. Monitor and optimize: Use Cloudflare analytics
  6. 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.

Async Squad Labs Team

Async Squad Labs Team

Software Engineering Experts

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.