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:

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

OptionTypeRequiredDefaultNotes
:storageatom (module)yesStorage adapter module — must implement Rindle.Storage
:variantskeyword listyesMap of variant name → variant spec (see below)
:allow_mimelist of stringsno[]Allowlist of MIME types accepted at upload validation
:allow_extensionslist of stringsno[]Allowlist of filename extensions
:max_bytespos_integer or nilnonilHard upper bound on upload size
:max_pixelspos_integer or nilnonilHard upper bound on image pixel count (image profiles)
:deliverykeyword listno[]Delivery policy (see Secure Delivery)

Variant Specs

Each variant is a {name, opts} pair. The variant spec controls how the processor derives the output:

OptionTypeRequiredDefaultNotes
:mode:fit, :fill, :cropyesResize mode
:widthpos_integer or nilnonilTarget width in pixels
:heightpos_integer or nilnonilTarget height in pixels
:format:jpeg, :png, :webp, :avifno:jpegOutput format
:quality1–100 or nilnonilOutput quality (0 = no override; processor default)

Each variant gets its own MediaVariant row with its own state — see Core Concepts 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:

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:

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:

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. 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:

# 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.

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.