FullstackPhoenix

Run Background Jobs With Oban in a Phoenix SaaS App

phoenixobanbackground-jobspostgressaas

A real SaaS application has work that should not happen inside a controller. Sending a welcome email, rebuilding a usage report, or expiring an invitation token should run in the background where a slow third party or a crashing process does not take a request down with it.

In this tutorial I install the oban feature with Live SaaS Kit, run the generated migration, and look at the queues it configures so I know where my own workers should live. The starting point is a Phoenix application that already uses Postgres for its main repo.

Step 1 - Install the oban feature

First step is to run the installer.

mix saaskit.feature.install oban
mix deps.get

The feature has no dependencies on other features, so it installs cleanly on a bare Phoenix project. Two packages land in mix.exs.

# mix.exs
{:oban, "~> 2.20.0"},
{:oban_web, "~> 2.11.0"},

Note that oban_web is included, which means I get the live job dashboard, not just the queue runner. If the admin feature is installed, the dashboard is mounted at /admin/oban and a link is added to the admin sidebar. Without admin, it is mounted at /oban in the main router scope.

Step 2 - Run the migration

Next step is to run the generated migration.

mix ecto.migrate

The migration file is a single line that delegates to Oban.

# priv/repo/migrations/TIMESTAMP_add_oban_jobs_table.exs
def up do
Oban.Migration.up(version: 12)
end
def down do
Oban.Migrations.down(version: 1)
end

The reason is that Oban manages its own schema. Oban.Migration.up/1 creates the oban_jobs table and the indexes the queue runner needs. Pinning version: 12 keeps the schema deterministic across environments even when the library upgrades.

Step 3 - Look at the config

The installer adds a config block to config/config.exs that defines the repo, the queues, and the plugins.

# config/config.exs
config :my_app, Oban,
engine: Oban.Engines.Basic,
repo: MyApp.Repo,
queues: [default: 10, mailers: 20, high: 50, low: 5],
plugins: [
{Oban.Plugins.Pruner, max_age: 3600 * 24},
{Oban.Plugins.Cron,
crontab: [
# {"0 2 * * *", MyApp.ExampleWorker},
]}
]

There are four named queues with explicit concurrency. The mailers queue at 20 is sized for transactional email; high at 50 is for fast, latency-sensitive work; low at 5 is for heavy batch jobs that should not crowd the others. So when I write a worker, I pick the queue that matches its character.

The Pruner removes completed jobs older than 24 hours. The Cron plugin is wired in with an empty crontab — that is where I add scheduled jobs as the app grows.

The supervisor is updated automatically too.

# lib/my_app/application.ex
{Oban, Application.fetch_env!(:my_app, Oban)},

And tests are kept synchronous so they do not race against the queue.

# config/test.exs
config :my_app, Oban, testing: :manual

:manual mode means jobs do not run during tests unless I drain the queue explicitly with Oban.drain_queue/1, or assert via Oban.Testing.assert_enqueued/1.

Step 4 - Write a small worker

The installer does not generate a sample worker, so I write one to confirm the queue is alive. I put it under lib/my_app/workers/.

# lib/my_app/workers/hello_worker.ex
defmodule MyApp.Workers.HelloWorker do
use Oban.Worker, queue: :default
@impl Oban.Worker
def perform(%Oban.Job{args: %{"name" => name}}) do
IO.puts("hello, #{name}")
:ok
end
end

Then I enqueue it from iex.

# iex -S mix
%{name: "world"} |> MyApp.Workers.HelloWorker.new() |> Oban.insert!()

The server log shows hello, world from the queue worker process, not from the iex shell. So I have confirmed that a job persisted to the database, was picked up by the supervisor, and ran in the background.

Step 5 - Open the Oban dashboard

If admin is installed, I sign in as a superuser and open /admin/oban. The dashboard shows the queues from step 3, the job I inserted in step 4, and any failures with their full error trace.

If admin is not installed, the dashboard is at /oban in the main router scope, and I am responsible for putting it behind authentication. Note that oban_web does not enforce any access control on its own.

Final Result

I now have a Phoenix application that can enqueue background work, watch it run in a real dashboard, and recover failures without leaving the app. A natural next step is to install error_tracker so any crash inside an Oban worker also lands in the error dashboard.