# Profiles
A `Rindle.Profile` is a compile-time module that declares how a particular
family of media is handled — which storage adapter to use, what variants
to derive, what upload constraints to enforce, and how delivery should
behave. Profiles are the single source of truth for a media domain in
your application; you typically have one per logical "thing" (avatars,
post images, document uploads, etc.).
## Defining a Profile
The minimal profile declares a storage adapter and at least one variant:
```elixir
defmodule MyApp.AvatarProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
variants: [thumb: [mode: :fit, width: 64, height: 64]]
end
```
The DSL validates options at compile time, so an unknown option, a malformed
variant spec, or a non-atom storage module all fail at `mix compile` — not at
runtime.
## DSL Options
| Option | Type | Required | Default | Notes |
| ------------------- | ------------------- | :------: | ------- | --------------------------------------------------------- |
| `:storage` | atom (module) | yes | — | Storage adapter module — must implement `Rindle.Storage` |
| `:variants` | keyword list | yes | — | Map of variant name → variant spec (see below) |
| `:allow_mime` | list of strings | no | `[]` | Allowlist of MIME types accepted at upload validation |
| `:allow_extensions` | list of strings | no | `[]` | Allowlist of filename extensions |
| `:max_bytes` | pos_integer or nil | no | `nil` | Hard upper bound on upload size |
| `:max_pixels` | pos_integer or nil | no | `nil` | Hard upper bound on image pixel count (image profiles) |
| `:delivery` | keyword list | no | `[]` | Delivery policy (see [Secure Delivery](secure_delivery.html)) |
## Variant Specs
Each variant is a `{name, opts}` pair. The variant spec controls how the
processor derives the output:
| Option | Type | Required | Default | Notes |
| ----------- | ----------------------------------- | :------: | ------- | -------------------------------------------------- |
| `:mode` | `:fit`, `:fill`, `:crop` | yes | — | Resize mode |
| `:width` | pos_integer or nil | no | `nil` | Target width in pixels |
| `:height` | pos_integer or nil | no | `nil` | Target height in pixels |
| `:format` | `:jpeg`, `:png`, `:webp`, `:avif` | no | `:jpeg` | Output format |
| `:quality` | 1–100 or nil | no | `nil` | Output quality (0 = no override; processor default) |
Each variant gets its own `MediaVariant` row with its own state — see
[Core Concepts](core_concepts.html) for the variant FSM. Variants are
queryable, regeneratable, and individually addressable.
## A Real-World Profile
Here is the canonical adopter profile from `test/adopter/canonical_app/profile.ex`:
```elixir
defmodule MyApp.MediaProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
variants: [thumb: [mode: :fit, width: 64, height: 64]],
allow_mime: ["image/png", "image/jpeg"],
max_bytes: 10_485_760
end
```
The profile module exposes a small public surface that Rindle uses internally:
- `MyApp.MediaProfile.storage_adapter/0` returns `Rindle.Storage.S3`
- `MyApp.MediaProfile.variants/0` returns `[thumb: %{mode: :fit, format: :jpeg, width: 64, height: 64}]`
- `MyApp.MediaProfile.upload_policy/0` returns the validation policy map
- `MyApp.MediaProfile.delivery_policy/0` returns the delivery policy map
- `MyApp.MediaProfile.recipe_digest/1` returns a stable hash of a variant's
options — when the recipe changes, all existing variants are detected as
`stale` and `mix rindle.regenerate_variants` will re-enqueue them.
## Multiple Variants
Most profiles declare more than one variant. The order in the keyword list
does not affect processing (variants are processed in parallel), but it does
affect deterministic ordering when iterating:
```elixir
defmodule MyApp.PostImageProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3,
variants: [
thumb: [mode: :fit, width: 200, height: 200, format: :webp, quality: 80],
large: [mode: :fit, width: 1200, height: 1200, format: :webp, quality: 85],
square: [mode: :crop, width: 400, height: 400, format: :webp]
],
allow_mime: ["image/png", "image/jpeg", "image/webp"],
allow_extensions: [".png", ".jpg", ".jpeg", ".webp"],
max_bytes: 25_165_824,
max_pixels: 50_000_000
end
```
Each variant generates a separate internal Oban job.
Variants are individually retryable and individually queryable for state.
## Storage Adapter Selection
The `storage:` option is per-profile, so you can mix adapters in one app:
```elixir
defmodule MyApp.AvatarProfile do
use Rindle.Profile,
storage: Rindle.Storage.S3, # avatars on S3
variants: [thumb: [mode: :fit, width: 64, height: 64]]
end
defmodule MyApp.AdminUploadProfile do
use Rindle.Profile,
storage: Rindle.Storage.Local, # admin uploads on local disk
variants: [original: [mode: :fit]] # no resizing — store as-is
end
```
Capability promises are documented centrally in
[Storage Capabilities](storage_capabilities.html). That guide is the source of
truth for the current adapter/provider matrix, the Cloudflare R2 compatibility
posture, and the reserved future resumable vocabulary.
At the profile layer, the important rule is simpler: choose an adapter whose
advertised capabilities match the flows your profile requires. For example:
- Private delivery requires `:signed_url`, or `Rindle.Delivery.url/3` returns
`{:error, {:delivery_unsupported, :signed_url}}`.
- Multipart direct-upload flows require `:multipart_upload`, or multipart
entrypoints fail with `{:error, {:upload_unsupported, :multipart_upload}}`.
- Reserved future resumable flows are additive and unsupported in v1.1; they
are not hidden behind the existing direct-upload API surface.
## Adapter Configuration
Adapter-specific configuration (S3 endpoint, bucket name, credentials) lives
in your application config — not on the profile. The profile only references
the adapter module:
```elixir
# config/runtime.exs (adopter-owned, NOT inside the Rindle dependency)
config :rindle, Rindle.Storage.S3,
bucket: System.fetch_env!("S3_BUCKET")
config :ex_aws, :s3,
scheme: "https://",
host: System.fetch_env!("S3_HOST"),
region: System.fetch_env!("S3_REGION"),
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
```
Per project decision: runtime DB and storage credentials are adopter-owned;
Rindle never reads secrets from a library-level config block.
## Recipe Digests and Stale Detection
Every variant has a `recipe_digest` — a stable hash computed from the variant's
options. Computing the digest canonicalizes option key ordering, so
`[mode: :fit, width: 64]` and `[width: 64, mode: :fit]` hash to the same
value.
When you change a variant spec (say, bumping `quality: 85` to `quality: 90`),
existing variants generated under the old spec are detected as `stale` because
their stored `recipe_digest` no longer matches the profile's current digest.
`mix rindle.regenerate_variants` walks stale rows and re-enqueues them. See
[Operations](operations.html).
## Validation Failure Modes
The Profile DSL fails at compile time for:
- Missing `:storage` or `:variants`
- Storage value that is not an atom (module reference)
- Variant spec missing `:mode`, or with `:mode` outside `[:fit, :fill, :crop]`
- Variant `:format` outside `[:jpeg, :png, :webp, :avif]`
- Variant `:quality` outside `1..100`
- Unknown top-level keys (e.g., a typo'd `varient:` instead of `variants:`)
Compile-time validation is intentional — invalid profiles should never reach
runtime, where they would surface as confusing errors deep inside the upload
or processing path.