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 :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:
# 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:
defp deps do
[
{:faker, "~> 0.17", only: [:test, :dev]},
]
And change it to this:
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
docker build -t myapp .
Run locally
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:
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:
: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
- Sign up or log in at sentry.io
- Create a new project — select Elixir as the platform
-
Copy the DSN from the project settings (looks like
https://abc123@o123456.ingest.sentry.io/789)
2. Set the DSN in production
Fly.io:
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/2orLogger.warning/2calls - 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:
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:
Sentry.capture_message("Billing webhook received unexpected event type",
level: :warning,
extra: %{event_type: event_type, payload: payload}
)
Capturing exceptions manually
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:
fly ssh console --app your-app-name
/app/bin/your_app remote
Then in IEx:
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:
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:
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:
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:
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:
# 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:
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:
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:
{"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:
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.