Launch pricing 50% off templates until 21 Sep

From the blog

Phoenix LiveView S3 Upload: Send Files Straight to S3 Without Proxying

By Liam Killingback ·

Phoenix LiveView S3 Upload: Send Files Straight to S3 Without Proxying

The default allow_upload in Phoenix LiveView is one of the nicest upload APIs in any framework. You get progress, validation, drag and drop and cancellation in about fifteen lines. Then the first customer uploads a 300 MB video, your server holds the whole stream, the filesystem it wrote to disappears on the next deploy, and you discover that the file landed on node 2 while the request that wants to process it is on node 1.

A Phoenix LiveView S3 upload fixes all of that by removing your server from the data path. The browser sends the bytes to S3. Your server signs a short-lived permission slip and learns one thing at the end: the object key. This post is the full working setup, including the parts most tutorials skip: bucket CORS, private buckets, enforcing the file size limit somewhere the browser cannot lie about it, and cleaning up the objects nobody ever finished uploading.

Why proxying uploads through Phoenix hurts

Server-side uploads are fine for a 2 MB avatar. They stop being fine when any of these are true:

  • The files are large. Every byte crosses your network twice, once into your app and once out to storage. You pay for that bandwidth and your request occupies a connection for minutes.
  • Your filesystem is ephemeral. On Fly.io, Render, Heroku or any container platform, the temp file written during the upload is gone after a deploy or a machine restart. Anything not already copied to durable storage is lost.
  • You run more than one node. A file on node 1’s disk does not exist for node 2. Background jobs pick up whichever node is free, so a job that expects a local path fails about half the time.
  • There is a proxy in front. Most load balancers have a request body limit and an idle timeout, and a slow mobile upload hits both.

External uploads sidestep all four. LiveView was built for this: allow_upload/3 takes an :external option, and the client uploads to whatever URL you sign.

How external uploads work in LiveView

Four pieces have to agree:

  1. allow_upload(socket, :document, external: &presign_upload/2) tells LiveView the bytes are not coming to you.
  2. Your presign function returns a meta map for each entry, including a :uploader key naming a JavaScript function.
  3. That JavaScript uploader, registered on the LiveSocket, does the actual POST to S3 and reports progress back to the server.
  4. consume_uploaded_entries/3 runs when the upload completes, and gives you the meta map you returned in step 2 so you can persist the key.

Note what never happens: your Elixir process never sees the file. That is the point, and it is also the main adjustment. Any validation that needs the bytes has to happen after the fact, which we will handle below.

Step 1: the bucket, CORS and a scoped key

The browser is now making a cross-origin request to S3, so the bucket needs a CORS rule. Without it the upload fails with an opaque network error and an empty console message, which is the single most common reason people give up on this.

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["POST", "PUT", "GET"],
    "AllowedOrigins": ["https://app.example.com", "http://localhost:4000"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Give the app an IAM user (or role) that can write only where you intend it to write:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::my-app-uploads/uploads/*"
    }
  ]
}

The signing credentials never leave your server, but the signed policy does, so the narrower the grant the better.

# config/runtime.exs
config :my_app, :uploads,
  bucket: System.fetch_env!("S3_BUCKET"),
  region: System.get_env("S3_REGION", "ap-southeast-2"),
  access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
  secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")

Step 2: allow_upload with :external

defmodule MyAppWeb.DocumentLive.Upload do
  use MyAppWeb, :live_view

  @max_size 100_000_000

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_keys, [])
     |> allow_upload(:document,
       accept: ~w(.pdf .png .jpg .jpeg),
       max_entries: 5,
       max_file_size: @max_size,
       external: &presign_upload/2
     )}
  end

  defp presign_upload(entry, socket) do
    config = Application.fetch_env!(:my_app, :uploads)
    key = "uploads/#{socket.assigns.current_user.id}/#{Ecto.UUID.generate()}/#{entry.client_name}"

    {:ok, fields} =
      MyApp.S3.sign_form_upload(config,
        key: key,
        content_type: entry.client_type,
        max_file_size: @max_size,
        expires_in: :timer.minutes(10)
      )

    meta = %{
      uploader: "S3",
      key: key,
      url: "https://#{config[:bucket]}.s3.#{config[:region]}.amazonaws.com",
      fields: fields
    }

    {:ok, meta, socket}
  end
end

Two details in the key are worth copying. It starts with the tenant or user id, so one customer’s objects are trivially separable from another’s, and it contains a UUID, so two people uploading invoice.pdf on the same day do not overwrite each other. Never build a key out of entry.client_name alone: it is attacker-controlled and can contain ../.

Step 3: signing the POST policy

S3 accepts a browser form post with a base64 policy document and a signature. The policy is where you state the rules the upload must satisfy, and S3 rejects anything that breaks them. This version is adapted from the module in the official LiveView uploads guide, with the public ACL removed because most buckets now block ACLs entirely.

defmodule MyApp.S3 do
  @moduledoc "Dependency-free S3 form upload signing (AWS Signature v4)."

  def sign_form_upload(config, opts) do
    key = Keyword.fetch!(opts, :key)
    content_type = Keyword.fetch!(opts, :content_type)
    max_file_size = Keyword.fetch!(opts, :max_file_size)
    expires_in = Keyword.fetch!(opts, :expires_in)

    now = DateTime.utc_now()
    expires_at = DateTime.add(now, expires_in, :millisecond)
    amz_date = amz_date(now)
    credential = credential(config, now)

    policy =
      %{
        "expiration" => DateTime.to_iso8601(expires_at),
        "conditions" => [
          %{"bucket" => config[:bucket]},
          ["eq", "$key", key],
          ["eq", "$Content-Type", content_type],
          ["content-length-range", 0, max_file_size],
          %{"x-amz-server-side-encryption" => "AES256"},
          %{"x-amz-credential" => credential},
          %{"x-amz-algorithm" => "AWS4-HMAC-SHA256"},
          %{"x-amz-date" => amz_date}
        ]
      }
      |> Jason.encode!()
      |> Base.encode64()

    fields = %{
      "key" => key,
      "Content-Type" => content_type,
      "x-amz-server-side-encryption" => "AES256",
      "x-amz-credential" => credential,
      "x-amz-algorithm" => "AWS4-HMAC-SHA256",
      "x-amz-date" => amz_date,
      "policy" => policy,
      "x-amz-signature" => signature(config, now, policy)
    }

    {:ok, fields}
  end

  defp signature(config, %DateTime{} = time, policy) do
    config
    |> signing_key(time)
    |> hmac(policy)
    |> Base.encode16(case: :lower)
  end

  defp signing_key(config, %DateTime{} = time) do
    ("AWS4" <> config[:secret_access_key])
    |> hmac(short_date(time))
    |> hmac(config[:region])
    |> hmac("s3")
    |> hmac("aws4_request")
  end

  defp credential(config, %DateTime{} = time) do
    "#{config[:access_key_id]}/#{short_date(time)}/#{config[:region]}/s3/aws4_request"
  end

  defp amz_date(%DateTime{} = time) do
    time
    |> DateTime.to_naive()
    |> NaiveDateTime.to_iso8601()
    |> String.split(".")
    |> List.first()
    |> String.replace(["-", ":"], "")
    |> Kernel.<>("Z")
  end

  defp short_date(%DateTime{} = time), do: time |> amz_date() |> String.slice(0..7)

  defp hmac(key, value), do: :crypto.mac(:hmac, :sha256, key, value)
end

The content-length-range condition is the important line. max_file_size in allow_upload is a client-side courtesy: it gives the user a nice error before anything is sent, and a modified client ignores it. The policy condition is checked by S3, so a 5 GB body against a 100 MB policy is rejected by the storage layer with a 400, and your bill is unaffected.

If your bucket is private (it should be, unless you are serving public marketing assets), you do not add an ACL field at all. Serve the objects later through a signed GET URL or a CDN that reads the bucket with an origin access identity.

Step 4: the JavaScript uploader

LiveView looks up meta.uploader in the uploaders map you pass to the socket. The function receives the entries and is responsible for reporting progress and errors back.

// assets/js/app.js
let Uploaders = {}

Uploaders.S3 = function (entries, onViewError) {
  entries.forEach(entry => {
    let { url, fields } = entry.meta
    let formData = new FormData()
    Object.entries(fields).forEach(([key, value]) => formData.append(key, value))
    formData.append("file", entry.file)

    let xhr = new XMLHttpRequest()
    onViewError(() => xhr.abort())
    xhr.onload = () => (xhr.status === 204 || xhr.status === 200 ? entry.progress(100) : entry.error())
    xhr.onerror = () => entry.error()

    xhr.upload.addEventListener("progress", event => {
      if (event.lengthComputable) {
        let percent = Math.round((event.loaded / event.total) * 100)
        if (percent < 100) { entry.progress(percent) }
      }
    })

    xhr.open("POST", url, true)
    xhr.send(formData)
  })
}

let liveSocket = new LiveSocket("/live", Socket, {
  uploaders: Uploaders,
  params: { _csrf_token: csrfToken }
})

Only call entry.progress(100) once the request has actually succeeded. Reporting 100 percent on send is how you end up consuming entries for files that never arrived.

Step 5: the template

<form id="upload-form" phx-submit="save" phx-change="validate">
  <div class="dropzone" phx-drop-target={@uploads.document.ref}>
    <.live_file_input upload={@uploads.document} />
    <p>Drop files here, or choose them above.</p>
  </div>

  <%= for entry <- @uploads.document.entries do %>
    <div class="entry">
      <span><%= entry.client_name %></span>
      <progress value={entry.progress} max="100"><%= entry.progress %>%</progress>
      <button type="button" phx-click="cancel" phx-value-ref={entry.ref}>Cancel</button>

      <%= for err <- upload_errors(@uploads.document, entry) do %>
        <p class="error"><%= error_to_string(err) %></p>
      <% end %>
    </div>
  <% end %>

  <button type="submit" disabled={@uploads.document.entries == []}>Save</button>
</form>
def handle_event("validate", _params, socket), do: {:noreply, socket}

def handle_event("cancel", %{"ref" => ref}, socket) do
  {:noreply, cancel_upload(socket, :document, ref)}
end

defp error_to_string(:too_large), do: "This file is too large."
defp error_to_string(:too_many_files), do: "You can upload five files at a time."
defp error_to_string(:not_accepted), do: "We accept PDF, PNG and JPG."

The validate handler looks pointless and is not. A phx-change binding on the form is what pushes client-side validation errors (size, extension, count) into @uploads, so without it the user gets no feedback until submit.

Step 6: consume the entries and store the key

def handle_event("save", _params, socket) do
  keys =
    consume_uploaded_entries(socket, :document, fn %{key: key}, entry ->
      {:ok, doc} =
        MyApp.Documents.create_document(%{
          user_id: socket.assigns.current_user.id,
          storage_key: key,
          filename: entry.client_name,
          content_type: entry.client_type,
          byte_size: entry.client_size
        })

      %{id: doc.id, storage_key: key}
      |> MyApp.Workers.ProcessDocument.new()
      |> Oban.insert()

      {:ok, key}
    end)

  {:noreply, update(socket, :uploaded_keys, &(&1 ++ keys))}
end

For external uploads the first argument to the consumer is the meta map you built in the presign function, not a %{path: path}. Store the key, not a full URL: the bucket, region or CDN in front of it will change one day, and a key survives that change.

Two things belong in the background job rather than in the LiveView. The first is anything that needs the bytes, since the request that had them was never yours. The second is anything slow. If you are extracting text or running a model over the file, Oban is the right home for it, and turning those documents into structured data is a natural next step once the object is safely in storage.

The gotchas that bite in production

Content types are claims, not facts. entry.client_type comes from the browser. A file called report.pdf with application/pdf in the form can be anything. In the worker, read the first few hundred bytes from S3 and check the magic number before you hand the object to a parser, and refuse the ones that do not match.

Orphaned objects. Every abandoned upload leaves an object with no database row, because the browser succeeded and the user closed the tab before submitting. Two fixes, and you want both: an S3 lifecycle rule that expires anything under a tmp/ prefix after a day, and a nightly job that lists recent keys and deletes the ones with no matching record.

Clock skew. Signature v4 embeds a timestamp. If the signing machine’s clock drifts more than fifteen minutes, S3 rejects every upload with RequestTimeTooSkewed. It is rare on managed platforms and instantly confusing when it happens.

Other S3-compatible providers. Cloudflare R2, Tigris on Fly.io and MinIO all speak the same POST policy protocol. Change the endpoint URL and set the region to whatever the provider asks for (auto for R2), and the module above keeps working. Check each provider’s CORS documentation, since the header names for the dashboard configuration differ.

A presigned PUT is the simpler cousin. If you do not need the size limit enforced by storage, you can presign a PUT URL with ExAws.S3.presigned_url/5 and have the JavaScript send the raw file with no form fields. It is less code. It also cannot express content-length-range, which is exactly the protection you want on a public signup form.

Testing an external upload

LiveViewTest simulates the client for you, and no bytes go to S3.

test "uploads a document", %{conn: conn, user: user} do
  {:ok, lv, _html} = live(log_in_user(conn, user), ~p"/documents/upload")

  doc =
    file_input(lv, "#upload-form", :document, [
      %{name: "invoice.pdf", content: "%PDF-1.7 fake", type: "application/pdf"}
    ])

  assert render_upload(doc, "invoice.pdf") =~ "100"

  lv |> element("#upload-form") |> render_submit()

  assert [%{filename: "invoice.pdf", storage_key: "uploads/" <> _}] =
           MyApp.Documents.list_documents(user)
end

Assert on the shape of the key as well as the row. Key layout is a decision that quietly becomes permanent, and a test is a cheap way to notice when someone changes it.

Skip the setup

Direct-to-S3 uploads are perhaps two hundred lines once the presigner, the uploader, the lifecycle rule and the reconcile job are all in place. If you would rather start from a codebase where that work is done, phx_saas ships uploads alongside auth, Stripe billing, transactional email and an admin area, and pdfai shows the same upload path feeding a document AI pipeline. The Builder Pass is every template, including the ones released later, for a single lifetime price.

If the uploads are the thing you charge for, counting them is its own problem. Aurora Meter meters usage on an ETS hot path, enforces the plan limit and reports the overage to Stripe, so “50 documents a month” becomes a plan rule rather than a scattered pile of conditionals.

Summary

A Phoenix LiveView S3 upload is the same LiveView upload API with the data path moved out of your app. allow_upload with :external plus a presign function, a small JavaScript uploader, and a consumer that stores the key. The parts to get right are the bucket CORS rule (or nothing works), the content-length-range condition in the policy (or your size limit is advisory), a key built from the tenant id and a UUID (never the client filename), and a cleanup story for the objects nobody finished.

Ship it that way and the size of the files stops being a property of your servers. It becomes a property of your bucket, which is the one thing in the stack that was designed to hold them.