In Phoenix 1.4, Phoenix adds support of HTTP/2 through using Cowboy 2, an Erlang-based web server. While Nginx has supported receiving HTTP/2 connections for quite some time, Nginx does not proxy HTTP/2 requests for other web servers to receive HTTP/2 requests[1]. This is problematic if you want to upgrade your Phoenix application to run with HTTP/2 support. If you want to use Cowboy2 and use SSL, you'll need to drop Nginx and serve the SSL certificates directly from your application.

Assumptions

For this post, we are going to assume that you already have SSL certificates available on the application's server. We will assume that the certificates were generated with Certbot. We are also going to assume that you've upgraded your dependencies to include {:plug_cowboy, "~> 2.0"}.

If you don't already have a set of Diffie Hellman parameters[2] to use with your SSL, generate a new set for extra security. Run this command on the server but be aware that it's very CPU-intensive and may take a while on a slow VPS.

$ openssl dhparam -out /etc/letsencrypt/dhparam.pem 4096

Application Configuration

Simple Mix.Config Configuration

Update your application's Endpoint configuration to add SSL support in config/prod.exs.

config :my_app, MyAppWeb.Endpoint,
  url: [host: "myapp.com", port: 443, scheme: "https"],
  http: [:inet6, port: 80],
  https: [
    port: 443,
    otp_app: :my_app,
    cipher_suite: :strong,
    keyfile: System.get_env("SSL_KEY_FILE"),
    certfile: System.get_env("SSL_CERT_FILE"),
    cacertfile: System.get_env("SSL_CACERT_FILE"),
    dhfile: System.get_env("SSL_DHPARAM_FILE")
  ]

Distillery 2.0

If you're using Distillery 2.0 releases, you'll have to update the release to handle run-time configuration. Create a new config file at rel/config/config.exs. In this config file, define the parts the need to be read at run-time similar to the section above.

Update your application's release configuration in rel/config.exs to tell Distillery to read the new config file right before running the application.

release :my_app do
  set overlays: [
    {:copy, "rel/config/config.exs", "etc/config.exs"}
  ]
  
  set config_providers: [
    {Mix.Releases.Config.Providers.Elixir, ["${RELEASE_ROOT_DIR}/etc/config.exs"]}
  ]
end

This tells Distillery to copy the newly defined config file at rel/config/config.exs to etc/config.exs when the release is unpacked. This also tells Distillery to use Mix.Config as a release provider. Distillery will read etc/config.exs from the release using the Mix.Config configuration provider[3] to load run-time values right before the application fully starts.

Server Configuration

Stop Nginx as a running service and disable it.

$ sudo service nginx disable
$ sudo service nginx stop

Next, update systemd config file to include the new run-time values are environment values provided to the application.

# /etc/systemd/system/my_app.service

[Service]
# ... Other config values

Environment=SSL_CERT_FILE=/etc/letsencrypt/live/myapp.com/cert.pem
Environment=SSL_CACERT_FILE=/etc/letsencrypt/live/myapp.com/chain.pem
Environment=SSL_KEY_FILE=/etc/letsencrypt/live/myapp.com/privkey.pem
Environment=SSL_DHPARAM_FILE=/etc/letsencrypt/dhparam.pem
Environment=RELEASE_ROOT_DIR=/opt/my_app

Once the configuration is updated, make sure to reload the systemd daemon.

$ sudo systemctl daemon-reload

Now you can deploy the new version of the release to serve SSL certificates directly from Phoenix.

Cerbot Certificate Renewal

Since we are no longer using Nginx, the application will need to handle certificate renewal with the assumption that certbot renew is triggered by a cron job. Certbot will ask for the contents of a file as a way to verify ownership of the server.

Create a new controller and action to handle reading the challenge file.

defmodule MyAppWeb.AcmeChallengeController do
  use MyAppWeb, :controller

  def show(conn, %{"challenge" => file_name}) do
    base_path = System.get_env("ACME_CHALLENGE_DIR")
    # Sanitize filename to prevent a directory-traversal attack
    safe_file_name = Path.basename(file_name)

    case File.read(Path.join([base_path, "/.well-known/acme-challenge", safe_file_name])) do
      {:ok, content} ->

        send_resp(conn, 200, content)
      _ ->
        send_resp(conn, 200, "Not Valid")
    end
  end

  def show(conn, _) do
    send_resp(conn, 200, "Not Valid")
  end
end

Update the router to include the new controller.

scope "/.well-known/acme-challenge", MyAppWeb do
  get "/:challenge", AcmeChallengeController, :show
end

Update the systemd config file to include the path where the challenge directory is under the variable ACME_CHALLENGE_DIR.

Wrap Up

The migration of removing Nginx as a web server proxy is straightfoward once the Phoenix application has proper run-time configuration. Now you can run your application with one less dependency and serve requests over HTTP/2.

Footnotes

1: Nginx HTTP2 Announcement
2: Purpose of Diffie Hellman parameters
3: Mix.Config Configuration Provider Documentation