DEV Community

Wannabe
Wannabe

Posted on

How to upload and SAVE images in Elixir

While working on faelib.com, I had to implement uploading images and saving them in the file system. Below is how it went.

Why I am writing this

I could not find plenty documentation or articles about this online. And the ones I did find were either too complex or didn't quite fit my use case. You can find one of the good ones here, but it implements quite different flow.

So, here's my journey that hopefully will save someone else a few hours of head-scratching!
(And if you're on a more experienced side, once you see my final solution, please do let me know why it's wrong.)

Use case

  • have a form to create tool and save it to database
  • as part of creating, upload an image (but don't save it to database)
  • save an image somewhere in the file system as <tool_id>.png
  • on the object info page, display that image

Sounds simple, right?

First attempt

I started with the basics. Phoenix provides a nice upload component out of the box.

# in the live view
@allowed_mime_types ~w(image/png)

def mount(_params, _session, socket) do
    {:ok,
     socket
     ...
     |> allow_upload(:image,
       accept: @allowed_mime_types,
       max_file_size: 5_000_000
     )}
  end

# in the heex template
<.live_file_input upload={@uploads.image} />
Enter fullscreen mode Exit fullscreen mode

Then, when the Save button is clicked, I saved the image to priv/static/images:

def handle_event("save", %{"tool" => tool_params}, socket) do
    save_tool(socket, socket.assigns.tool, tool_params)
end

defp save_tool(socket, nil, params) do
    ...

    case Tools.create_tool(params) do
      {:ok, tool} ->
        save_image(socket, tool)

        # put flash, redirect or do whatever here
        {:noreply, socket}

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

defp save_image(socket, tool) do
    consume_uploaded_entries(socket, :image, fn %{path: path}, _entry ->
      dest = "/priv/static/images/tools/"
      File.cp!(path, dest)
      {:ok, "/images/tools/#{tool.id}.png"}
    end)
 end
Enter fullscreen mode Exit fullscreen mode

And finally, when it comes to displaying images, the code is as simple as:

<img src="/images/tools/<tool_id>.png} />
Enter fullscreen mode Exit fullscreen mode

Worked like a charm, because Phoenix already has priv/static/images served as static paths, in _web.ex file:

def static_paths, do: ~w(assets fonts images favicon.ico robots.txt sitemap.xml)
Enter fullscreen mode Exit fullscreen mode

Everything worked beautifully on localhost - upload an image, see it appear, celebration deployment time! 🎉

Plot Twist: Enter Fly.io

I have Faelib deployed on Fly.io. Feeling confident, I deployed my changes... And that's when things got interesting. My perfectly working image upload system suddenly... wasn't working. At all. ENOENT error.

Coming from the native iOS development, debugging code in webdev feels like a special form of punishment, many times so in production.

After some investigation, I discovered that priv/static doesn't exist in the release version of the app. Well, that explains things! 🤔

The Plot Thickens

After some googling on how to upload images at all

(that's where AI does us all a disservice, I should've started by finding out how to do it properly in the first place!),

I figured out that I have to have a dedicated folder in the file system to save my images. And then serve them from there (sounds reasonable, but I had no idea how.)

I started with creating the folders. I did it manually, via connecting to fly.io machine: fly ssh console -s then cd, mkdir and all that stuff. But that did not feel right, it should happen automatically.

As Fly.io provides a Docker file for the deployment, all I had to do is modify it with:

RUN mkdir -p /images/tools
Enter fullscreen mode Exit fullscreen mode

Immediate problem, my app can't write to this folder. That's a quick fix, my Docker command turned into:

RUN mkdir -p /images/tools && \
  chown -R nobody: /images && \
  chmod -R 755 /images
Enter fullscreen mode Exit fullscreen mode

Now, I save images to /images/tools and serve them from /images/tools. Should work, right?

It did work... for about two hours. Then all my images just vanished.

Turns out (after another share of googling) Fly.io has virtual file system, which means it wipes all data when you deploy new code or when the machine automatically restarts. I should have known that! 🫣 (It's not even the first app that I host with them. Mea culpa!)

The Solution: Persistent Volumes

Finally, I discovered Fly.io volumes - persistent storage that survives deployments and restarts. They provide pretty decent documentation on how to work with volumes. I did just what I was instructed to do.

# fly.toml
[mounts]
  source = "tool_images"
  destination = "/app/bin/images"
Enter fullscreen mode Exit fullscreen mode

then fly volumes create <my_volume_name> and fly deploy.

The only thing left to do is serving the images from that folder. So...

Here's the overview of the final working solution:

  • I have created a volume on my machine at /app/bin/images; that's where I save the uploaded images
    (after another debugging session I found out that I need the path to start with "/app")
    I gave that folder proper permissions with chmod -R 755 bin/images command.

  • I specified paths for uploading and serving, depending on the environment.

# dev.exs
config :faelib,
  uploads_directory: Path.join("priv/static/images", "tools"),
  static_paths: "priv/static"

# prod.exs
config :faelib,
  uploads_directory: Path.join("images", "tools"),
  static_paths: "images"
Enter fullscreen mode Exit fullscreen mode

I still want my uploading to work in the localhost as it used to work in the beginning, saving images to priv/static/ folder.
As a small touch, I also excluded that folder from git, there's no need to commit the image files.

  • I instruct my app how to serve the files from the upload folder:
# endpoint.ex

plug Plug.Static,
    at: "images",
    from: {__MODULE__, :static_directory, []},
    gzip: false

def static_directory do
    Faelib.ImageHandler.statics_path()
end
Enter fullscreen mode Exit fullscreen mode
  • The code in live view and heex template almost did not change. I just had to provide the image paths depending on the environment that I am in. I made a dedicated module for that (to be completely honest, I already had it from the very start, but did not mention it above to not distract you from how silly I was):
defmodule Faelib.ImageHandler do
  # see endpoint.ex for Plug that serves images from "/images"
  @image_path "/images/tools"

  def get_image_path(tool) do
    Path.join(@image_path, "#{tool.id}.png")
  end

  def destination_image_path(tool) do
    Path.join(uploads_dir(), "#{tool.id}.png")
  end

  # used in endpoint.ex
  def statics_path do
    Application.get_env(:faelib, :static_paths)
  end

  defp uploads_dir do
    Application.get_env(:faelib, :uploads_directory)
  end
end
Enter fullscreen mode Exit fullscreen mode

Uploading image:

# in the live view
@allowed_mime_types ~w(image/png)

def mount(_params, _session, socket) do
    {:ok,
     socket
     ...
     |> allow_upload(:image,
       accept: @allowed_mime_types,
       max_file_size: 5_000_000
     )}
  end

# in the heex template
<.live_file_input upload={@uploads.image} />
Enter fullscreen mode Exit fullscreen mode

Handling uploaded image:

def handle_event("save", %{"tool" => tool_params}, socket) do
    save_tool(socket, socket.assigns.tool, tool_params)
end

defp save_tool(socket, nil, params) do
    ...

    case Tools.create_tool(params) do
      {:ok, tool} ->
        save_image(socket, tool)

        # put flash, redirect or do whatever here
        {:noreply, socket}

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

defp save_image(socket, tool) do
    consume_uploaded_entries(socket, :image, fn %{path: path}, _entry ->
      dest = Faelib.ImageHandler.destination_image_path(tool)
      File.cp!(path, dest)
      {:ok, "/images/tools/#{tool.id}.png"}
    end)
  end
Enter fullscreen mode Exit fullscreen mode

Serving image:

<img
  class={"#{@size} rounded-lg"}
  src={Faelib.ImageHandler.get_image_path(@tool)}
  alt={@tool.name}
/>
Enter fullscreen mode Exit fullscreen mode

Looking Ahead

While this solution works well for now, I know it's not the end of the story. As the application grows, I'll need to look into more scalable solutions like AWS S3 or similar cloud storage services. But for an MVP or small application? This setup does the job perfectly.

Now, that I wrote it...

I will remind you why I did it, because after all this long read you have probably forgotten. I wrote it with two thoughts in mind:

  1. If it worked for me, it can work for someone else. Hopefully, saving them some time.
  2. Despite the working solution, it still very well can suck. So I am asking you, more experienced devs: What did I do wrong?

Top comments (0)