Introduction

So you want to deploy your Phoenix app. You head to the docs, and in typical Elixir fashion you find a number of thorough and well-written guides. Unfortunately, they're all guides for deploying to cloud PaaS platforms. Well, there is a Releases guide, but it only teaches you how to build a release. Not how to deploy it!

But what if you want to deploy to an actual server? Maybe you want to avoid vendor lock-in, or maybe you just want to manage your own system. You know, like the good old days. This is an opinionated guide for deploying an Elixir/Phoenix app to an actual server. VPS, bare metal - it doesn't matter. Just a server.

Nothing here is novel, but there aren't really any guides that spell it out.

I am going to spell it out.

Prerequisites

This guide has two prerequisites: a Phoenix app (naturally), a domain, and a server running Debian 13 (Trixie). For the server, at least 1GB of RAM is advisable for building the containers.

It doesn't really matter where you get your server from. In fact, that's the benefit of this approach: a Linux server is fungible, and if you're unhappy with your provider you can easily move. No vendor lock-in!

Anyway, once you have SSH access to a server running Debian 13 (Trixie) you can move on.

Setup

To run the app, we're going to need some tools:

  • Debian 13 (Trixie) for the OS, as mentioned above

  • Podman to build and run our containers

  • systemd to supervise those containers

  • Caddy to act as a reverse proxy and to provision certificates

We choose Debian as our Linux distro because it has a long history of stable governance and because it's popular enough to be offered as an image by virtually every VPS provider.

We choose Podman because it integrates nicely with systemd (which comes with Debian), and we choose Caddy because it works well out of the box with astonishingly little configuration.

Installing Podman

We can get podman from Debian's repository. Run the following commands:

sudo apt update
sudo apt install -y podman

# The above installs dbus-user-session but does not start it
systemctl --user start dbus

# Linger is needed to prevent containers shutting down after logout
sudo loginctl enable-linger $USER

Installing Caddy

To install Caddy, follow the official instructions for Debian. There is also a caddy package maintained by Debian, but Caddy recommends against it.

Preparing your app

Generating a Containerfile

For simplicity, we are going to be building a container right on the server. Conveniently, Phoenix has a command built right in to generate a Release and a Containerfile (Dockerfile) for us!

In your dev environment, run the following in your app's root directory:

mix phx.gen.release --docker

# Optional
mv Dockerfile Containerfile
mv .dockerignore .containerignore

You can then commit the generated files to your repo. Renaming the files to the standard container variants is entirely optional (Podman will accept either).

(Optional) Forcing HTTPS

We're going to use Caddy as a reverse proxy for HTTPS support, but for better security you should also configure your Phoenix app to redirect HTTP to HTTPS and enable HSTS.

In your dev environment, add the force_ssl option to the Endpoint config in your config/prod.exs file:

config :your_app, YourAppWeb.Endpoint,
  force_ssl: [
    hsts: true,
    rewrite_on: [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto],
  ],
  ...

The :hsts option enables HSTS, a security feature that will prevent a browser from allowing any further insecure (non-HTTPS) connections to your origin for a long period of time. If for some reason you intend to serve insecure traffic from your domain, do not enable HSTS. However, in practice, this requirement has become rare and HSTS is preferred.

The :rewrite_on options are needed because the reverse proxy will obscure the true host/port/protocol used by the client.

For more information, consult the docs for Plug.SSL.

Getting the app on the server

Now we need to get the source code on the server so we can build the container. If you publish all of your code publicly (like we do), you could simply git clone your repo with no credentials. If your code is private, most git forges have a way to add a read-only SSH key (sometimes called a "Deploy Key").

Alternatively, you could use SFTP, rsync, or a similar tool. It's up to you!

Once you have your app's source code on the server you can move on.

Building and Running

We now have everything we need to build and run the containerized app.

Building the container

We can use podman build to build a container right on the server. Note that you could build the container in CI and upload it to a container repository somewhere, but this is a minimal guide and we're taking the simple path. Not everyone has a container repository lying around!

On the server, run the following:

podman build -t your_app /path/to/your_app

Just like that, Podman will build a container for your app with all of its dependencies included.

The -t argument will tag the container as your_app, and it will be stored locally as localhost/your_app. Remember that Docker and Podman are primarily meant for running containers from registries (like DockerHub or Quay.io), so the localhost part tells Podman that the image is stored locally.

Potential footgun: the image is stored locally per-user. Unlike Docker, Podman is "rootless" by default and runs containers under the current user. Which means it also builds and stores containers under the current user. So, if you were to then try to run this container as another user (like root), Podman will give you an error telling you it doesn't exist. This is the sort of thing you can lose a couple of hours debugging if you're not aware of it. You know, not that, like, I would ever make a mistake like that, or anything...

Creating the systemd unit (Quadlet)

At this point we could run our container with podman run, but instead we're going to create a systemd unit file to supervise it. This approach has many benefits: systemd will start the app on boot, restart it if it crashes, handle logging, and so on.

If you've used systemd before this is going to look very familiar. Podman adds some new sections to its unit files, which it calls "Quadlets" for some reason (I have no idea where they conjured this name from). But, fundamentally, it's the same idea.

Create a file at ~/.config/containers/systemd/your_app.container. Note that this directory is in your user's home dir, because we're going to run the container under your current user. Rootless, remember?

To that file, add the following:

[Unit]
Description="Very cool app"

[Container]
Image=localhost/your_app
PublishPort=4000:4000
Environment="PHX_HOST=yourdomain.com" "SECRET_KEY_BASE=really_long_secret"

[Service]
Restart=always

[Install]
WantedBy=multi-user.target

The PublishPort field is in the format outside:inside, with the former being the port outside of the container and the latter being inside the container. If you wanted to run another Phoenix app on the same host, you would then use PublishPort=4001:4000 for the new one so that the ports on the host do not collide.

The Environment field contains environment variables which will be passed to your app. If you prefer, you can put your variables in an env file like prod.env and use EnvironmentFile= instead:

EnvironmentFile=/path/to/prod.env

Remember that you can generate a secure SECRET_KEY_BASE using mix phx.gen.secret. Just run that command in your dev environment and copy it over.

Running the container

To actually run the container, we can use systemctl.

Run the following commands:

# Reload so that systemd finds the unit file
systemctl --user daemon-reload

# Start the container
systemctl --user restart your_app

# Test the app to see if it's responding
curl localhost:4000

You may also find the following commands helpful:

# View status information from systemd
systemctl --user status your_app

# View logs for your app (-e scrolls to the end)
journalctl --user -e -u your_app

# Restart your app
systemctl --user restart your_app

Note that if you make any changes to the unit you will need to run systemctl --user daemon-reload again before restarting it. This is, unfortunately, easy to forget.

Getting on the Web

The app is now listening on localhost at port 4000. Now we can point a domain to the server's IP and reverse proxy that local port to the public internet.

Configuring DNS

For your domain, create an A record pointing to your server's IPv4 address, and optionally create an AAAA record pointing to its IPv6 address.

Consult your DNS provider's documentation for more information.

Opening up the ports

If you are using a cloud service, many providers have a built-in cloud firewall that blocks incoming traffic on most or all ports by default. In order for web traffic to reach your server, you need to ensure that ports 80 and 443 (for HTTP and HTTPS respectively) are open to the public internet.

Consult your provider's documentation for more information.

Configuring the reverse proxy

Now that your server is exposed to the public internet, we can configure our reverse proxy (Caddy) to proxy the internal port (4000) to the outside world (ports 80 and 443). Caddy will also provision a certificate and terminate TLS for easy HTTPS.

When Caddy was installed, a systemd unit and a configuration file were created.

Edit /etc/caddy/Caddyfile with the following contents, substituting your domain:

yourdomain.com {
  reverse_proxy localhost:4000
}

These three lines are all that we need!

To apply the changes, reload Caddy:

sudo systemctl reload caddy

Generally with Caddy it's best to use reload (rather than restart) as restarting the reverse proxy will cause some downtime. Reloading Caddy will sever WebSocket connections by default, though this is configurable.

Note that unlike your app the Caddy service is not running as a user service.

You may also find the following commands helpful:

# View status information
sudo systemctl status caddy

# View logs for Caddy (-e scrolls to the end)
sudo journalctl -e -u caddy

Testing your app

At this point, your app is now available on the public internet. If you visit your domain in your web browser, you should see your app. You will also see requests logged in your app's logs.