Home Posts Post Search Tag Search

LiveView 13 - Chapter 5: AWS S3
Published on: 2025-12-18 Tags: elixir, Blog, LiveView, Ecto, Html/CSS, Phoenix, S3, AWS, IAM

Finally, if you have an Amazon S3 account, upload the files there instead of saving locally.

There are several steps, so here’s a structured list:

Product Images → S3 (Phoenix) — Work List

1. Product Image Model Decisions


  • Decide: one image per product or multiple <br>
    • Exactly one image per product
  • Decide: store full URL vs store S3 object key <br>
    • Store a URL (not a key or metadata blob)
  • Decide: overwrite image on update or version it <br>
    • Overwrite the existing image when a new one is uploaded <br>

2. S3 Bucket (Product Images Only)


  • Create one standard S3 bucket: pento-images
  • Choose region: US East (Ohio) us-east-2
  • Block public access
  • Enable bucket owner enforced ownership
  • Decide bucket naming per environment (or prefixes) <br>

3. IAM Access for the Phoenix App


  • Create IAM user or role with a policy that allows only: <br>
    • Upload product images
    • Read product images
    • Delete product images
  • Store credentials as environment variables
  • Access scope: PutObject, GetObject, DeleteObject, ListBucket
  • Attach the policy to the user
  • Test the policy by creating an access key using CLI <br> Install AWS CLI for testing: <br>
    sudo apt update
    sudo apt install -y unzip curl
    curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
    unzip awscliv2.zip
    sudo ./aws/install
    aws --version
    aws configure --profile pento_admin
    # AWS Access Key ID → from pento_admin
    # AWS Secret Access Key → from pento_admin
    # Default region → e.g., us-east-1
    # Default output format → json
    echo "test file" > test.txt
    aws s3 cp test.txt s3://your-bucket-name/products/test.txt
    aws s3 ls s3://your-bucket-name/products/

4. Object Structure Convention


  • Define product image key format: products/:product_id/image.ext
  • Handle filename collisions: always overwrite
  • Replacement images: delete old image from S3 when uploading a new one <br>

5. Phoenix App Configuration

  • Add S3 as an upload backend (add dependencies for backend)
  • Configure bucket name and region in config.dev.exs: <br>
    config :pento, Pento.Uploads,
    bucket: "pento-images",
    region: "us-east-1",
    access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
    secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY")
    <br>
  • Store credentials in .env and ensure .gitignore excludes it
  • Ensure dev/stage/prod are isolated: <br>
    • Dev: pento-images-dev or prefix dev/products/:id/image.ext
    • Prod: pento-images or prefix products/:id/image.ext

6. Product Schema Changes


  • Add dependencies for AWS:
{:ex_aws, "~> 2.3"},
{:ex_aws_s3, "~> 2.3"},
{:hackney, "~> 1.19"},
{:sweet_xml, "~> 0.7"}

  • Configure in dev.exs: <br>

    # Configure pento uploads
    config :pento, Pento.Uploads,
    bucket: "pento-images",
    region: "us-east-1",
    access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
    secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY")
    
    # Configure ExAws
    config :ex_aws,
    access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
    secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"),
    region: System.get_env("AWS_REGION")
  • Add image_url field to products table:

mix ecto.gen.migration add_image_url_to_products
  • Nullable is fine; delete the file from S3 when the product is deleted <br>

7. Product Create / Edit Flow


  • Accept image uploads in product forms; upload to S3:
defp save_product(socket, :new, product_params) do
  case Catalog.create_product(socket.assigns.current_scope, product_params) do
    {:ok, product} ->
      with [image_url | _] <-
             consume_uploaded_entries(socket, :image, fn entry, _ ->
               upload_s3_file(entry, product.id)
             end),
           {:ok, _product} <-
             Catalog.update_product(socket.assigns.current_scope, product, %{
               "image_url" => image_url
             }) do
        {:noreply,
         socket
         |> put_flash(:info, "Product created successfully")
         |> push_navigate(
           to: return_path(socket.assigns.current_scope, socket.assigns.return_to, product)
         )}
      else
        {:error, %Ecto.Changeset{} = changeset} ->
          {:noreply, assign_form(socket, changeset)}
        [] ->
          {:noreply,
           socket
           |> put_flash(:info, "Product created (no image uploaded)")
           |> push_navigate(
             to: return_path(socket.assigns.current_scope, socket.assigns.return_to, product)
           )}
        _other ->
          {:noreply,
           socket
           |> put_flash(:error, "An unexpected error occurred")}
      end

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end

defp params_with_image(socket, params) do
  if socket.assigns.product.id do
    path =
      consume_uploaded_entries(socket, :image, fn entry, _ ->
        upload_s3_file(entry, socket.assigns.product.id)
      end)
      |> List.first()

    Map.put(params, "image_upload", path)
  else
    params
  end
end

defp upload_s3_file(%{path: path} = entry, product_id) do
  ext = Path.extname(filename)
  key = "#{s3_prefix()}/#{product_id}/image#{ext}"

  {:ok, _resp} =
    ExAws.S3.put_object("pento-images", key, File.read!(path))
    |> ExAws.request()

  {:ok, key}
end

defp s3_prefix do
  case Mix.env() do
    :dev -> "dev/products"
    :test -> "test/products"
    :prod -> "products"
  end
end
  • Upload image on create/update, persist reference, rollback cleanly if upload fails

8. Image Access in the UI

def image_url(product) do
  case product.image_url do
    nil -> nil
    url ->
      {:ok, url} =
        ExAws.S3.presigned_url(
          ExAws.Config.new(:s3),
          :get,
          "pento-images",
          url,
          expires_in: 3600
        )

      url
  end
end

<div>
  <img alt="product image" width="200" src={image_url(@product)} />
</div>

9. Image Replacement & Deletion

  • Only one image per product; delete old image when uploading a new one
  • Delete S3 image when product is deleted:
def handle_event("delete", %{"id" => id}, socket) do
  product = Catalog.get_product!(socket.assigns.current_scope, id)

  case delete_s3_image(product) do
    :ok ->
      {:ok, _} = Catalog.delete_product(socket.assigns.current_scope, product)
      {:noreply, stream_delete(socket, :products, product)}

    {:error, reason} ->
      {:noreply, socket |> put_flash(:error, "Failed to delete image: #{inspect(reason)}")}
  end
end

defp delete_s3_image(%{image_url: nil}), do: :ok

defp delete_s3_image(%{image_url: key}) do
  case ExAws.S3.delete_object("pento-images", key) |> ExAws.request() do
    {:ok, _resp} -> :ok
    {:error, reason} -> {:error, reason}
  end
end

10. Validation & Constraints

def mount(params, _session, socket) do
  {:ok,
   socket
   |> assign(:return_to, return_to(params["return_to"]))
   |> allow_upload(:image,
     accept: ~w(.jpg .jpeg .png .gif),
     max_entries: 1,
     max_file_size: 9_000_000,
     auto_upload: true
   )
   |> apply_action(socket.assigns.live_action, params)}
end