Pro Deployment
Viewing latest docs.
Switch version: v3

Deployment

How to get your app running in production

Before deploy

Before deployment, we’ve found an issue in production with the new content security policy headers. Basically, you need to add wss://... and your domain to the :content_security_policy default_src array, like below.

config.exs
config :my_app, :content_security_policy, %{
  default_src: [
    ....
    "wss://yourdomain.com",
    "wss://yourdomain.com/live/websocket"
  ]
}

Read more:

Removing CSP

If you don’t care about content security policies, you can remove this config and then in router.exs change:

elixir
# Change this:
plug(:put_secure_browser_headers, %{
  "content-security-policy" =>
    ContentSecurityPolicy.serialize(
      struct(ContentSecurityPolicy.Policy, PetalPro.config(:content_security_policy))
    )
})

# To this:
plug :put_secure_browser_headers

Faker dependency and the Landing Page

If you haven’t changed the landing page yet, there will be references to Faker. If you don’t remove references to Faker, you’ll need to find this line in mix.exs:

elixir
defp deps do
  [
    {:faker, "~> 0.17", only: [:test, :dev]},
  ]

And change it to this:

elixir
defp deps do
  [
    {:faker, "~> 0.17"},
  ]

A word on environment variables

In production, Petal Pro requires the following environment variables to be set:

  • SECRET_KEY_BASE
  • DATABASE_URL
  • PHX_HOST
  • PORT
  • RESEND_API_KEY — transactional email via Resend

Optional but recommended:

  • SENTRY_DSN — error tracking auto-activates when set
  • MCP_ADMIN_TOKEN — bearer token for the admin MCP API

If you’re deploying to Fly.io, the core variables should be automatically taken care of. However, if you’re testing prod on your dev machine or using a different deployment mechanism, you’ll need to set these yourself.

SECRET_KEY_BASE and DATABASE_URL are secrets and need to be protected. Follow best practices for managing secrets on the platform you are deploying to.

Docker

Petal Pro v4 ships a production-ready Dockerfile in the repo root. It uses a multi-stage build: a builder stage compiles the Elixir release (including npm asset pipeline), and a lean Debian runner stage contains only the compiled release.

Build the image

shell
docker build -t myapp .

Run locally

shell
docker run -p 4000:4000 \
  -e SECRET_KEY_BASE="your_secret_key_base" \
  -e DATABASE_URL="ecto://user:pass@host/db" \
  -e PHX_HOST="localhost" \
  -e PORT="4000" \
  -e RESEND_API_KEY="re_..." \
  myapp

Key runtime environment variables

Variable Required Notes
SECRET_KEY_BASE Yes Generate with mix phx.gen.secret
DATABASE_URL Yes PostgreSQL connection string
PHX_HOST Yes Your domain (no https://)
PORT Yes Usually 4000
RESEND_API_KEY Yes Transactional email
SENTRY_DSN No Error tracking
MCP_ADMIN_TOKEN No Admin AI tool access

The Dockerfile targets Elixir 1.17.2 / OTP 27.0.1 on Debian Bookworm slim. To change versions, update the ARG lines at the top of the file.

Deployment with Fly.io

To deploy to Fly.io, use the following guide: Deploy to Fly.io

Error Tracking

Petal Pro uses Sentry for error tracking. It auto-activates when the SENTRY_DSN environment variable is set — no code changes required.

How It Works

Sentry is configured in config/runtime.exs:

elixir
if sentry_dsn = System.get_env("SENTRY_DSN") do
  config :sentry, dsn: sentry_dsn
end

A Sentry logger handler is attached at application startup, which captures errors logged via Elixir’s Logger:

elixir
:logger.add_handler(:sentry_handler, Sentry.LoggerHandler, %{
  config: %{metadata: [:file, :line]}
})

In development, if SENTRY_DSN is not set, errors go to your local logs and nothing is sent to Sentry. Set the DSN in dev if you want to test the integration.

Setup

1. Create a Sentry project

  1. Sign up or log in at sentry.io
  2. Create a new project — select Elixir as the platform
  3. Copy the DSN from the project settings (looks like https://abc123@o123456.ingest.sentry.io/789)

2. Set the DSN in production

Fly.io:

shell
fly secrets set SENTRY_DSN=https://your-dsn@sentry.io/project-id

Other platforms: Set SENTRY_DSN as an environment variable in your deployment configuration.

That’s it. Deploy and Sentry will start receiving errors.

What Gets Captured Automatically

  • Unhandled exceptions in controllers and LiveViews
  • LiveView crash reports
  • Any Logger.error/2 or Logger.warning/2 calls
  • Errors from Oban workers
  • Anything that bubbles up through the OTP supervision tree

Adding Custom Context

Setting user context

Call this when a user authenticates to attach their identity to all subsequent errors in that process:

elixir
Sentry.Context.set_user_context(%{
  id: user.id,
  email: user.email,
  name: user.name
})

A good place to do this is in your auth hooks or controller plugs.

Capturing custom messages

For non-exception events you want to track:

elixir
Sentry.capture_message("Billing webhook received unexpected event type",
  level: :warning,
  extra: %{event_type: event_type, payload: payload}
)

Capturing exceptions manually

elixir
try do
  risky_operation()
rescue
  e ->
    Sentry.capture_exception(e, stacktrace: __STACKTRACE__, extra: %{context: "my_function"})
    reraise e, __STACKTRACE__
end

Verifying It Works

The easiest test is to trigger an error from IEx on your production instance and confirm it appears in Sentry within a minute or two.

From a Fly.io console:

shell
fly ssh console --app your-app-name
/app/bin/your_app remote

Then in IEx:

elixir
Sentry.capture_message("Test error from IEx", level: :error)

Check your Sentry project — the event should appear shortly.

You can also check that the DSN is loaded correctly:

elixir
Application.get_env(:sentry, :dsn)

If this returns nil, SENTRY_DSN isn’t set in the environment.

Environment Behavior

Environment SENTRY_DSN set? Behavior
Production Yes Errors sent to Sentry
Production No Errors logged only (misconfiguration — set the DSN)
Dev/Test No Errors logged locally, nothing sent to Sentry
Dev/Test Yes Errors sent to Sentry (useful for testing the integration)

Rate Limiting

Petal Pro uses Hammer with an ETS backend for rate limiting. It’s applied to auth endpoints out of the box and is straightforward to add anywhere else.

What’s Rate Limited by Default

Endpoint Key Limit
API sign-in (POST /api/session) IP address 5 requests per minute
API registration (POST /api/register) IP address 3 requests per hour

Both use a plug that runs before the action and returns a 429 if the limit is exceeded.

How It Works

The rate limiter is defined in lib/petal_pro/rate_limiter.ex:

elixir
defmodule PetalPro.RateLimiter do
  use Hammer, backend: :ets
end

It starts as part of the application supervision tree. All state lives in ETS — no Redis or external dependency needed.

Call hit/3 with a key, time window (in milliseconds), and request limit:

elixir
PetalPro.RateLimiter.hit("some_key", :timer.minutes(1), 10)
# => {:allow, count} | {:deny, retry_after_ms}

Adding Rate Limiting to a Controller

Use a plug that calls hit/3 and halts on denial:

elixir
defmodule MyAppWeb.MyController do
  use MyAppWeb, :controller

  plug :rate_limit when action in [:create]

  defp rate_limit(conn, _opts) do
    ip = conn.remote_ip |> :inet.ntoa() |> to_string()

    case PetalPro.RateLimiter.hit("my_action:#{ip}", to_timeout(minute: 1), 10) do
      {:allow, _count} ->
        conn

      {:deny, _retry_after} ->
        conn
        |> put_status(:too_many_requests)
        |> json(%{errors: %{detail: "Too many requests"}})
        |> halt()
    end
  end
end

Key naming

Use a descriptive prefix with a per-user or per-IP discriminator:

elixir
# Per IP
"sign_in:#{ip}"

# Per user
"api:#{user_id}"

# Per email (good for password reset)
"reset:#{email}"

Adding Rate Limiting to a LiveView

LiveViews don’t have plugs, so check the limit inside the event handler:

elixir
def handle_event("submit", _params, socket) do
  user_id = socket.assigns.current_user.id

  case PetalPro.RateLimiter.hit("my_form:#{user_id}", to_timeout(minute: 1), 5) do
    {:allow, _count} ->
      # proceed
      {:noreply, do_the_thing(socket)}

    {:deny, _retry_after} ->
      {:noreply, put_flash(socket, :error, "You're doing that too fast. Try again in a minute.")}
  end
end

Time Windows

Use to_timeout/1 (available in Phoenix controllers) or :timer functions:

elixir
to_timeout(minute: 1)   # 60_000 ms
to_timeout(hour: 1)     # 3_600_000 ms
:timer.minutes(1)       # same as above, works anywhere
:timer.hours(1)

Response When Rate Limited

Controllers return HTTP 429 with a JSON body:

json
{"errors": {"detail": "Too many requests"}}

LiveViews should use put_flash/3 to show an error message — there’s no HTTP status code to set.

Clearing a Rate Limit in Dev

Restart your dev server — ETS is in-memory and doesn’t persist across restarts. That’s the fastest way to clear all limits.

If you need to clear a specific key without restarting, call delete/1 from IEx:

elixir
PetalPro.RateLimiter.delete("sign_in:127.0.0.1")

Configuration

There’s no central config file for limits — each call site defines its own window and limit inline. This is intentional: different actions have different risk profiles, and collocating the limit with the code that enforces it makes it easier to reason about.

To change a limit, find the RateLimiter.hit/3 call and update the second (window) or third (max requests) argument.