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.
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 flyWhat this creates
- A Phoenix 1.7 JSON API with
GET /api/items,GET /api/items/:id,POST /api/items, andGET /api/health. - Ecto SQL with PostgreSQL, an
itemsmigration, and a seed file. - Oban started in the application supervision tree, a
defaultqueue with 10 workers, thePrunerplugin, 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.SampleWorkermodule. - A Swoosh
Mailerusing the local adapter, plus Finch for its HTTP client. - A
Dockerfileand afly.tomlfor Fly.io.
Generated shape
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
flyctlCLI if you deploy with the generatedfly.toml.
Run and verify
cd my-jobs-api
mix deps.get
mix ecto.setup
mix phx.servermix ecto.setup runs all three migrations, so the oban_jobs table exists before the server starts. Create an item through the REST API:
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:
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.
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
endReturning {:error, reason} or raising marks the attempt as failed, and Oban retries until max_attempts is reached.
Representative configuration
config :my_jobs_api, Oban,
repo: MyJobsApi.Repo,
queues: [default: 10],
plugins: [Oban.Plugins.Pruner]
config :my_jobs_api, MyJobsApi.Mailer, adapter: Swoosh.Adapters.LocalAdd queues to the queues list when different job types need separate concurrency limits.
Tests
mix testThe 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 obanrequires--elixir-orm ecto-sql. The CLI rejects Oban withnone,myxql, orecto_sqlite3.--elixir-email swooshgenerates theMailermodule and configuration, not a mail template or a sender.--elixir-realtime noneomits theUserSocketand 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:
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix releaseconfig/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:
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
| Symptom | Check |
|---|---|
relation "oban_jobs" does not exist | Run mix ecto.migrate; the Oban migration is a regular Ecto migration. |
Jobs stay in the available state | Confirm Oban is in the supervision tree and the job's queue is listed in queues. |
POST /api/items returns 400 | Wrap fields in an item object; the controller matches %{"item" => attrs}. |
| Invalid items return 500, not 422 | ItemController.create/2 uses with without an else or fallback controller; add one that renders changeset errors. |
| Emails never leave the machine | Swoosh.Adapters.Local keeps mail in memory; configure a provider adapter. |
Next steps
- Open the Stack Builder.
- Read the Elixir ecosystem docs.
- Compare with Phoenix LiveView with Ecto and PostgreSQL.