Getting Started with Elixir Phoenix on Fly

Image of Author
February 1, 2022 (last updated October 5, 2022)

Introduction

This is an opinionated link-first getting started guide. It is my preferred setup as of last authoring, and as such is also liable to change as I change tooling opinions over time, etc.

This guide will walk through setting up an Elixir Phoenix Liveview full-stack app deployed on Fly on a custom domain. The codebase will include/use PostgreSQL, TailwindCSS, Github Actions, Github CLI, and asdf.

Below I will use g as an alias for git, and g cm as an alias for git commit --message.

Prepare machine

https://asdf-vm.com/

https://fly.io/docs/getting-started/installing-flyctl/

https://cli.github.com/

asdf install [erlang, elixir, postgres] [version]
asdf global [erlang, elixir, postgres] [version]
fly version upgrade
brew upgrade gh

Create new Phoenix app

https://hexdocs.pm/phoenix/installation.html

https://hexdocs.pm/phoenix/up_and_running.html

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html

mix local.hex
mix archive.install hex phx_new
mix phx.new app
cd app
mix ecto.create

Setup Github repo

https://cli.github.com/

g init
gh repo create app --public --source=.
g add .
g cm "First commit."
g push -u origin main

g = git and g cm = git commit --message

Deploy on Fly

https://fly.io/docs/flyctl/

https://fly.io/docs/getting-started/elixir/

fly launch

Setup a custom domain

https://fly.io/docs/app-guides/custom-domains-with-fly/#setting-the-a-and-aaaa-records

https://en.wikipedia.org/wiki/List_of_DNS_record_types

https://domains.google.com

fly ips list

Use the listed IPs to update your DNS resource. IPv4 uses A records, IPv6 uses AAAA records. It will look something like:

[custom domain name] | A | 1 hour | 111.222.333.444
[custom domain name] | AAAA | 1 hour | 1111:2222:3::4:5555
fly certs create [custom domain name]

Enforce TLS (redirect http -> https)

https://hexdocs.pm/phoenix/using_ssl.html#force-ssl

From the documentation above, host: nil enables dynamic https redirects based on the origin of the request. So, this means that https will be enforced on both the fly domain and the custom domain.

# config/prod.exs

config :app, App.Endpoint,
  force_ssl: [
    hsts: true,
    host: nil,
    rewrite_on: [:x_forwarded_proto]
  ]

Create CICD pipeline with Github Actions

https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions

https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers#running-jobs-in-containers

fly auth token
gh secret set FLY_API_TOKEN --body [insert auth token here]
# .github/workflows/cicd.yml

name: CICD
on: [push]
env:
  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      SECRET_KEY_BASE: [mix phx.gen.secret]
    steps:
      - uses: actions/checkout@v2
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "24.0"
          elixir-version: "1.13.2"
      - run: mix deps.get
      - run: mix test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: superfly/flyctl-actions@1.1
        with:
          args: "deploy"

A public repo might want to store the SECRET_KEY_BASE as an env var instead of hard-coding it in the repo. The postgres service is port-mapped to localhost:5432 with appropriate credentials as per config/test.exs, which is why there's no need for DATABASE_URL in the env (at least for now).

Add authentication, authorization, and users

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Auth.html

mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.migrate

Assign current_user to liveviews inside live sessions

https://hexdocs.pm/phoenix_live_view/security-model.html#mounting-considerations

https://hexdocs.pm/phoenix_live_view/security-model.html#live_session-and-live_redirect

One good way to keep track of the current user is to wrap our liveview routes in a live_session with the on_mount option pointing to a custom UserAuthLive module (or whatever you want to call it). This allows us to pass a current_user to our liveviews contained within. We are using the previously generated phx.gen.auth code for a lot of the heavy lifting here.

# app_web/controllers/user_auth_live.ex

defmodule AppWeb.UserAuthLive do
  import Phoenix.LiveView
  alias App.Accounts

  def on_mount(:default, _params, %{"user_token" => user_token} = _session, socket) do
    socket =
      assign_new(socket, :current_user, fn ->
        Accounts.get_user_by_session_token(user_token)
      end)

    {:cont, socket}
  end
end
# lib/app_web/router.ex

defmodule AppWeb.Router do
  scope "/", AppWeb do
    pipe_through :browser

    live_session :user_auth, on_mount: AppWeb.UserAuthLive do
      pipe_through :require_authenticated_user

      # live routes here
    end
  end
end

We should now have access to socket.assigns.current_user in our liveviews.

Add todos or whatever

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Live.html

https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3

https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3

https://hexdocs.pm/phoenix/contexts.html

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html

mix phx.gen.live Todos Todo todos title:string done:boolean
# lib/accounts/user.ex
schema :users do
  has_many :todos, Todo
end

# lib/todos/todo.ex
schema :todos do
  belongs_to :user, User
end

Whatever contexts and liveviews you generate, the flow of logic will likely be something like: Your liveviews call your context functions and provide the current_user as appropriate. This can all be thoroughly tested via the generated fixture classes in generators.

Add TailwindCSS and remove generated html and css

https://fly.io/phoenix-files/tailwind-standalone/

https://github.com/phoenixframework/tailwind

From the fly article linked above by Chris McCord:

The phx.new project generator will add Tailwind support in a future release

This is so likely to change, and will have such a large impact on generators, that I'm not going to elaborate on this step.

Next Steps

Email delivery solution (with Magic Links), OAuth with Social Sign-On (SSO).

Conclusion

At this point you should have a full-stack Elixir Phoenix LiveView app deployed to Fly on a custom domain.

Thank you for reading :D