Gatlin.io Icon
Image of Austin Gatlin's face
February 1, 2022 (last updated March 1, 2022)

Getting Started: Phoenix on Fly

Introduction

This is a link-first getting started guide which I define and defend in the linked blog post. In brief, the goal is to create a sequential list of steps, each with many links to resources. The end result is a minimal yet robust codebase as a starting point for a project. The anti-goal is an example repo or website, which would quickly become outdated and laborious to maintain. The code examples in each section are elaborations on the links regarding what worked at the time of last usage, but are to be considered less trustworthy than the links, and whatever they tell you to do.

This guide is opinionated in that it is essentially my favorite setup as of last authoring, and 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