Deploying Phoenix apps on bare metal
I run a couple of internal and public applications written in Phoenix on our infrastructure. While services like fly.io exist and are amazing, sometimes it's both cheaper (and more secure) to run small internal applications on your own hardware. It's certainly more fun. Thru the years I've developed my own process of deploying the application and this post will document the current procedure.
I'm open to comments and I would love to hear your thoughts -- so please write to me andrei(a)0x7f.dev.
Requirements
Since we are not using Docker the system requirements are fairly low for a normal sized app. I run everything in LXD containers, but a more conventional KVM VM can be used as well, it shouldn't really matter.
- 512 MB of RAM (I find that for the first build it's beneficial to have at least 1 GB, which I scale down later)
- 1 CPU with 1 socket
- 8GB of disk space (if you need more mount a SMB or NFS share)
- a database (I host one Postgres instance for all apps)
Initial setup
My OS of choice is Ubuntu 20.04, but I'm slowly switching to Alpine Linux. The biggest hurdle for me personally is lack of SystemD (I know - blasphemy; but it's easy and it works). Once the OS is installed I run:
$ apt update
$ apt install vim git curl unzip libssl-dev make automake autoconf libncurses5-dev gcc
$ git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.10.2
$ echo ". \$HOME/.asdf/asdf.sh" >> .bashrc
$ source .bashrc
$ asdf plugin add elixir
$ asdf plugin add erlang
And now we can setup the application deployment and startup.
$ git init --bare app
Then, on your local machine add a new origin to the repo git origin add prod user@server:app
and push git push prod
. This will get your app on the server and will be the way of deploying your app. But first we need to do some manual work!
Back on your server:
$ git clone app/ release
$ cd release
$ asdf install
$ mix deps.get --only prod
$ MIX_ENV=prod mix compile
Now we setup our .env
file:
$ cat > .env << EOF
SECRET_KEY_BASE=<SOME KEY>
DATABASE_URL=ecto://user:password@db_host/db
MIX_ENV=prod
EOF
And we can setup the SystemD service:
$ cat > /etc/systemd/system/app.service << EOF
[Unit]
Description=App
After=network.target
[Service]
EnvironmentFile=/root/release/.env
WorkingDirectory=/root/release
ExecStart=/root/release/_build/prod/rel/<App name>/bin/<App name> start
ExecStop=/root/release/_build/prod/rel/<App name>/bin/<App name> stop
User=root
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
$ systemctl daemon-reload
$ systemctl enable app
The app can't be started just yet, we still need to cut the first release! The easiest way I found was to add the deploy script and force push the existing commit so it triggers:
$ cat > /root/app/hooks/post-receive << EOF
#!/bin/bash
TARGET="/root/release"
GIT_DIR="/root/app"
BRANCH="master"
while read oldrev newrev ref
do
if [ "$ref" = "refs/heads/$BRANCH" ];
then
source $HOME/.asdf/asdf.sh
echo "Ref $ref received. Deploying ${BRANCH} branch to production..."
git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH
cd $TARGET
export $(xargs < .env)
mix deps.get --only prod
mix compile
mix phx.digest
mix release --overwrite
mix ecto.migrate
systemctl restart app
else
echo "Ref $ref received. Doing nothing: only the ${BRANCH} branch may be deployed on this server."
fi
done
EOF
$ chmod +x /root/app/hooks/post-receive
And on your computer:
$ git commit --amend --no-edit
$ git push -f prod
And that's it, this should recompile, create a digest, and setup a release. After it's done it will restart the app (and that will actually start it for the first time).
Next steps
If you want, you can now setup a reverse proxy (like nginx, caddy, or haproxy) with SSL, but that's outside of the scope of this guide.