Getting Started with Elixir Phoenix on Fly

Image of Author
February 1, 2022 (last updated May 17, 2024)

Introduction

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

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] [version]
asdf global [erlang, elixir] [version]
brew upgrade gh
brew upgrade flyctl

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

Deploy on Fly

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

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

fly launch

This command will take you all the way to cloud deployment on a fly.dev URL.

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]
gh secret set SECRET_KEY_BASE --body $(mix phx.gen.secret)
# .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: ${{ secrets.SECRET_KEY_BASE }}
    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

This code does a lot. I encourage you to familiarize yourself with it over time.

Auth for liveview

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 particularly noteworthy part of the generated code is that it has wired up the authentication for liveviews. Look in router.ex and notice how some of the live routes are wrapped in live_session calls with defined on_mount functions. Those on_mount functions are defined in user_auth.ex. on_mount is a lifecycle method for liveview connections. It is called both in the initial html request and during initial socket upgrade. The generated code is assigning socket.current_user when possible, and running a few other contextual checks.

The short version of this is that 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 similar across many applications. Your liveviews enable user interactions. Those interactions a capture in liveview actions and are passed to contexts. You can pass the current_user in to your contexts as appropriate (if only they can access data, for example). This can all be thoroughly tested via the generated fixture classes in generators.

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