GuidesElixir

Phoenix REST API with Oban Background Jobs

Create a Phoenix JSON API with Ecto SQL, PostgreSQL, Oban background jobs, Swoosh email, and Fly.io deploy files using Better Fullstack.

Updated October 6, 2026phoenixobanectobackground-jobs

Use this stack when a Phoenix API needs durable background work stored in PostgreSQL, so jobs survive restarts and retry without a separate queue service.

npm create better-fullstack@latest my-jobs-api -- \  --ecosystem elixir \  --elixir-web-framework phoenix \  --elixir-orm ecto-sql \  --elixir-auth none \  --elixir-api rest \  --elixir-realtime none \  --elixir-jobs oban \  --elixir-email swoosh \  --elixir-testing ex_unit \  --elixir-deploy fly

What this creates

  • A Phoenix 1.7 JSON API with GET /api/items, GET /api/items/:id, POST /api/items, and GET /api/health.
  • Ecto SQL with PostgreSQL, an items migration, and a seed file.
  • Oban started in the application supervision tree, a default queue with 10 workers, the Pruner plugin, a migration that creates the Oban tables, and a second migration that upgrades them to the schema version Oban 2.24 requires.
  • A MyJobsApi.Workers.SampleWorker module.
  • A Swoosh Mailer using the local adapter, plus Finch for its HTTP client.
  • A Dockerfile and a fly.toml for Fly.io.

Generated shape

txt
my-jobs-api/
├── mix.exs
├── Dockerfile
├── fly.toml
├── .env.example
├── config/
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── runtime.exs
│   └── test.exs
├── lib/
│   ├── my_jobs_api/
│   │   ├── application.ex
│   │   ├── catalog.ex
│   │   ├── catalog/item.ex
│   │   ├── mailer.ex
│   │   ├── repo.ex
│   │   └── workers/sample_worker.ex
│   └── my_jobs_api_web/
│       ├── router.ex
│       └── controllers/
│           ├── health_controller.ex
│           └── item_controller.ex
├── priv/repo/migrations/
│   ├── 20260101000000_create_items.exs
│   ├── 20260101000002_add_oban_jobs.exs
│   └── 20261007000000_upgrade_oban_jobs_to_v14.exs
└── test/

Prerequisites

  • Elixir 1.16 or newer with a matching Erlang/OTP release.
  • PostgreSQL. Oban stores jobs in the same database as the application through MyJobsApi.Repo.
  • The flyctl CLI if you deploy with the generated fly.toml.

Run and verify

bash
cd my-jobs-api
mix deps.get
mix ecto.setup
mix phx.server

mix ecto.setup runs all three migrations, so the oban_jobs table exists before the server starts. Create an item through the REST API:

bash
curl -X POST http://localhost:4000/api/items \
  -H "Content-Type: application/json" \
  -d '{"item":{"name":"First item","description":"Created over HTTP"}}'

Enqueue the sample worker

The worker logs its arguments and returns :ok. Nothing in the scaffold enqueues it, so insert a job yourself from iex -S mix phx.server:

elixir
item = MyJobsApi.Catalog.list_items() |> List.last()

%{"item_id" => item.id}
|> MyJobsApi.Workers.SampleWorker.new()
|> Oban.insert()

The default queue picks the job up and logs Processed Oban job with args=%{"item_id" => ...}. To enqueue from application code instead, call the same pipeline from Catalog.create_item/1 after a successful insert.

elixir
defmodule MyJobsApi.Workers.SampleWorker do
  use Oban.Worker, queue: :default, max_attempts: 3

  @impl Oban.Worker
  def perform(%Oban.Job{args: args}) do
    require Logger
    Logger.info("Processed Oban job with args=#{inspect(args)}")
    :ok
  end
end

Returning {:error, reason} or raising marks the attempt as failed, and Oban retries until max_attempts is reached.

Representative configuration

elixir
config :my_jobs_api, Oban,
  repo: MyJobsApi.Repo,
  queues: [default: 10],
  plugins: [Oban.Plugins.Pruner]

config :my_jobs_api, MyJobsApi.Mailer, adapter: Swoosh.Adapters.Local

Add queues to the queues list when different job types need separate concurrency limits.

Tests

bash
mix test

The generated test covers /api/health. config/test.exs sets testing: :manual for Oban, so the default queue and the Pruner do not start under ExUnit and jobs inserted during a test are not run. In this mode Oban refuses to start until its migrations are current; the test alias migrates the test database first. 20260101000002_add_oban_jobs.exs creates the Oban tables at schema version 12 and 20261007000000_upgrade_oban_jobs_to_v14.exs upgrades them to version 14, so a database migrated before the upgrade file existed only needs mix ecto.migrate. To run enqueued jobs inside a test, call Oban.drain_queue(queue: :default).

When to choose it

Choose Oban when jobs must be persisted, retried, and inspected in SQL, and the application already depends on PostgreSQL. Choose --elixir-jobs quantum for cron-style schedules that do not need persistence; Quantum does not require Ecto.

Compatibility notes

  • --elixir-jobs oban requires --elixir-orm ecto-sql. The CLI rejects Oban with none, myxql, or ecto_sqlite3.
  • --elixir-email swoosh generates the Mailer module and configuration, not a mail template or a sender.
  • --elixir-realtime none omits the UserSocket and channel modules.

Deployment notes

fly.toml builds from the generated Dockerfile, sets PHX_HOST and PORT=4000, forces HTTPS, and sets min_machines_running = 1. Oban runs inside the application, so if Fly stopped every idle machine, queued and scheduled jobs would wait until a request started one again. Fly still stops machines above that minimum when traffic drops. The Dockerfile runs mix release with MIX_ENV=prod; run the same build locally with:

bash
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix release

config/runtime.exs requires two values in production. SECRET_KEY_BASE must be set or the release stops at boot, and DATABASE_URL is the only production database setting, so the API and Oban cannot reach PostgreSQL without it. Set both as secrets:

bash
fly secrets set SECRET_KEY_BASE="$(mix phx.gen.secret)" DATABASE_URL="ecto://..."

POOL_SIZE (default 10) and ECTO_SSL (true enables SSL) are optional.

The release does not include a migration task. Add one before the first deploy so the items and oban_jobs tables exist in production. Replace the local Swoosh adapter with a real provider adapter in config/runtime.exs before sending mail.

Troubleshooting

SymptomCheck
relation "oban_jobs" does not existRun mix ecto.migrate; the Oban migration is a regular Ecto migration.
Jobs stay in the available stateConfirm Oban is in the supervision tree and the job's queue is listed in queues.
POST /api/items returns 400Wrap fields in an item object; the controller matches %{"item" => attrs}.
Invalid items return 500, not 422ItemController.create/2 uses with without an else or fallback controller; add one that renders changeset errors.
Emails never leave the machineSwoosh.Adapters.Local keeps mail in memory; configure a provider adapter.

Next steps

GitHub Sponsors