We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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>
<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") -
Store credentials in
.envand ensure.gitignoreexcludes it -
Ensure dev/stage/prod are isolated:
<br>
-
Dev:
pento-images-devor prefixdev/products/:id/image.ext -
Prod:
pento-imagesor prefixproducts/:id/image.ext
-
Dev:
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_urlfield 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