By DevOps on Sat 01 June 2019
inThis is a gentle introduction to getting your Elixir / Phoenix app up and running on a server at Digital Ocean. It starts from zero, assuming minimal experience with servers.
We build build and deploy to the same server, using Erlang releases to run the code under systemd. It uses the mix_deploy library to handle deployment tasks.
You can run everything (build, web and db) fine on Digital Ocean's smallest $5/month plan, but this guide uses their managed databases service so you don't need to manage the database.
We will be using a boilerplate Phoenix project with PostgreSQL database. It assumes you are running macOS on your dev machine and Ubuntu 18.04 on the server.
These instructions are based on this working example application and the principles described in the blog post "Best practices for deploying Elixir apps".
This post includes the basic instructions on how to prepare your existing Elixir/Phoenix application
for deployment using mix_deploy. For more detailed information, please consult
the README
of the mix-deploy-example
repo.
If you have any questions, open an issue on GitHub or
ping me on the #elixir-lang
IRC channel on Freenode, I am reachfh
.
It is recommended to first get the template running, then prepare your own project for deployment.
NOTE: This guide works with CentOS 7, Ubuntu 16.04, Ubuntu 18.04 and Debian 9.4. If you are not sure which distro to use, choose Ubuntu 18.04. The approach here works for dedicated servers and cloud instances as well.
The actual work of building and deploying releases is handled by simple shell scripts which you run on the build server or from your dev machine via ssh, e.g.:
ssh -A deploy@web-server
cd build/mix-deploy-example
git pull
# Build release
bin/build
# Extract release to target directory on local machine, creating current symlink
bin/deploy-release
# Run database migrations
bin/deploy-migrate
# Restart the systemd unit
sudo bin/deploy-restart
Go to Digital Ocean (affiliate link) and create a Droplet (virtual server).
~/.ssh/id_rsa.pub
file.
Create an ssh key, if you
don't have one already.
On Mac OS, you can copy your SSH key to clipboard using cat ~/.ssh/id_rsa.pub | pbcopy
.The defaults for everything else are fine. Click the "Create" button.
Note the IP address of your new droplet in the Digital Ocean UI.
Configure ~/.ssh/config
on your local dev machine so you can
connect to the server.
# Change the IP address below to the actual IP address of your Droplet
Host web-server
HostName 123.45.67.89
For security, we use two operating system user accounts, the deploy
user to
build and deploy the app, and the app
user to run the app.
Connect to the web server as root:
ssh root@web-server
Create the deploy
user:
useradd -m -s /bin/bash deploy
Configure sudo
to allow the deploy
user run commands as root without a password:
echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/10-app-deploy
There are more sophisticated ways to manage users. We normally manage user accounts with Ansible.
deploy
user.Create the .ssh
directory and set permissions:
mkdir -p ~deploy/.ssh
chown deploy:deploy ~deploy/.ssh
chmod 700 ~deploy/.ssh
Allow the ssh key you set for the droplet root user to log into the deploy
user account:
cp ~root/.ssh/authorized_keys ~deploy/.ssh
chown deploy:deploy ~deploy/.ssh/authorized_keys
chmod 600 ~deploy/.ssh/authorized_keys
Exit the ssh session and connect again using the deploy
user.
ssh -A deploy@web-server
If it doesn't work, check that the ssh key on your dev machine
(.ssh/id_rsa.pub
) is in the ~/.ssh/authorized_keys
file for the deploy
user and
check the file permissions. Try with -vv
or look at /var/log/auth.log
on
the server.
The -A
flag on the ssh command gives the session on the server access to your
local ssh keys. If your local user can access a GitHub repo, then you can do it
on the server without having to set up keys on the server.
Make sure that the deploy
user can run commands with sudo:
sudo -s
exit
As the deploy
user on the build machine, create the build dir:
mkdir -p ~/build
Check out the app source:
cd ~/build
git clone https://github.com/cogini/mix-deploy-example # or your app repo
cd mix-deploy-example
Install Erlang, Elixir and Node.js from OS packages:
LANG=en_US.UTF-8 sudo bin/build-install-deps-ubuntu
We generally use ASDF to manage build tools rather than OS packages. That allows us to precisely specify versions and install multiple versions at once.
See the instructions on the Elixir website for more details on installing Elixir and dependencies.
Most apps use a database. You could install the database on the same Droplet as you run your app. It works fine and is cheaper, but then you have to manage the db. This guide uses Digital Ocean's managed databases service.
In the Digital Ocean UI, select "Create | Databases".
While the database is being created, in the "Getting started" section of the page, click the bullet point that says "Secure this database cluster." Under "Configure inbound sources" select your droplet and click "Allow these inbound sources only." This ensures that only your application server can connect to the database.
If you are creating only one database per app, you can use the defaultdb
database and
doadmin
user that the setup wizard created for you. However, it is better to
create a separate database and database user for each app environment.
Configuration for a Phoenix application can be split into three parts:
These settings are the same for all servers, though they may differ
between dev, test and production. We handle this with the normal Phoenix
config files in config/config.exs
, config/test.exs
and config/prod.exs
.
Ideally we would be able to run the same software build in our staging and production environments, allowing us to test exactly the same thing as we will run. Settings in these config files are compiled into the release files, so for security they should not include secrets like db passwords.
These settings depend on the environment the application is running in, e.g. the hostname of the db server and secrets like the db password. We store these external to the application release and load them from files or a configuration system like AWS Systems Manager Parameter Store or etcd.
In this example, we use the Distillery Mix.Config provider
to load prod.secret.exs
data from a file on startup. Another good option is
a TOML config file.
The file is stored in the application's configuration directory under /etc
, allowing
it to be managed by startup scripts like mix_deploy deploy-sync-config-s3
or configuration management tools like Ansible.
These settings are dynamic and may change every time the application starts.
For example, if we are running in an AWS auto scaling group, the IP address of
the server normally changes every time it starts. We load them at runtime when
the app starts using scripts like deploy-runtime-environment-file
which reads
it from cloud-init
.
On your build machine, build the app by running the build script:
bin/build
In addition to the normal Phoenix build steps, this command sets up the deploy scripts by
running the following mix_systemd
and mix_deploy
commands:
# NOTE: you don't have to run these commands manually
mix systemd.init
mix systemd.generate
mix deploy.init
mix deploy.generate
The configuration is minimal. We just change the name of the OS user that the
app runs under to app
in config/prod.exs
:
config :mix_deploy,
app_user: "app",
app_group: "app"
# Minimal
config :mix_systemd,
app_user: "app",
app_group: "app"
On your build machine, copy config/prod.secret.exs.sample
to config/prod.secret.exs
and
edit it to match the settings for your database.
cp config/prod.secret.exs.sample config/prod.secret.exs
use Mix.Config
config :mix_deploy_example, MixDeployExample.Repo,
username: "doadmin",
password: "CHANGEME",
database: "defaultdb",
hostname: "db-postgresql-sfo2-xxx-do-user-yyy-0.db.ondigitalocean.com",
port: 25060,
ssl: true,
pool_size: 15
config :mix_deploy_example, MixDeployExampleWeb.Endpoint,
secret_key_base: "CHANGEME2"
You can generate a unique value for secret_key_base
using this command:
mix phx.gen.secret 64
Build the app and make a release:
bin/build
Run this once to set up the system for the app, creating users, directories, etc:
sudo bin/deploy-init-local
Copy secrets to the app runtime configuration directory:
cp config/prod.secret.exs /etc/mix-deploy-example/config.exs
chown deploy:app /etc/mix-deploy-example/config.exs
chmod 644 /etc/mix-deploy-example/config.exs
Deploy the release to the local machine:
# Extract release to target directory, creating current symlink
bin/deploy-release
# Run database migrations
bin/deploy-migrate
# Restart the systemd unit
sudo bin/deploy-restart
Make a request to the server:
curl -v http://localhost:4000/
You can get a console on the running release:
sudo -i -u app /srv/mix-deploy-example/bin/deploy-remote-console
You can also have a look at the logs:
sudo systemctl status mix-deploy-example
sudo journalctl -r -u mix-deploy-example
You can roll back the release with the following:
bin/deploy-rollback
sudo bin/deploy-restart
Listening on port 4000 might be fine if it's behind a load balancer, otherwise we need to make the app available on port 80. There are two ways to do this:
iptables
(see Port forwarding with iptables)After you complete this step, you should be able to access your website in the browser by navigating to your droplet's public IP address.
The steps necessary to get SSL set up with your Phoenix application depend
on the approach that you took in the previous step. If you are forwarding ports
using iptables
, then you should set up SSL in your application's endpoint,
as described in Phoenix docs.
You can get an SSL certificate for free from Let's Encrypt.
If you are running behind an Nginx reverse proxy, you should instead set up SSL in Nginx. The necessary steps are described in Digital Ocean's tutorials.
Following are the steps used to set up this repo. You can do the same to add it to your own project. This repo is built as a series of git commits, so you can see how it works step by step.
mix phx.new your_app
mix deps.get
cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development
mix.lock
to gitpackage-lock.json
to gitAdd library to deps:
{:distillery, "~> 2.0"}
Generate initial distillery config files in the rel
dir:
mix release.init
Check the rel
directory into git.
Increase network ports in rel/vm.args
.
Add runtime config provider to rel/config.exs
:
environment :prod do
set config_providers: [
# Make sure to set the correct path to your config.exs file
{Mix.Releases.Config.Providers.Elixir, ["/etc/mix-deploy-example/config.exs"]}
]
end
Create a config/prod.secret.exs.sample
that you can use to generate production
configuration files on the build server.
In order for the bin/deploy-migrate
script to work properly, you need to set up
a custom Distillery command. The instructions on how to do so are described in the post
Running Ecto migrations in production releases with Distillery custom commands.
If your build server and production server are the same machine, you can also skip this
step and just run your migrations with MIX_ENV=prod mix ecto.migrate
.
Create a .tool-versions
file in the root of your project, describing the versions
of OTP, Elixir, and Node that you will be building with:
erlang 21.3
elixir 1.8.2
nodejs 10.16.0
Add libraries to deps from Hex:
{:mix_systemd, "~> 0.1.0"},
{:mix_deploy, "~> 0.1.0"}
Or from GitHub:
{:mix_systemd, github: "cogini/mix_systemd", override: true},
{:mix_deploy, github: "cogini/mix_deploy"},
end
Add rel/templates
and bin/deploy-*
to .gitignore
:
echo '/rel/templates' >> .gitignore
echo '/bin/deploy-*' >> .gitignore
Copy shell scripts from the bin/
directory of the mix-deploy-example
repo to the bin/
directory of your project.
These scripts build your release or install the required dependencies:
build
build-install-asdf
build-install-asdf-deps-centos
build-install-asdf-deps-ubuntu
build-install-asdf-init
build-install-asdf-macos
build-install-deps-centos
build-install-deps-ubuntu
This script verifies that your application is running correctly:
validate-service
Check these scripts into git.
In config/prod.exs
, uncomment or add this line so that Phoenix can run correctly in a release:
config :phoenix, :serve_endpoints, true
In the same file, configure mix_deploy
and mix_systemd
to run your application
as the app
user. This step is mandatory:
config :mix_deploy,
app_user: "app",
app_group: "app"
# Minimal
config :mix_systemd,
app_user: "app",
app_group: "app"
Still in prod.exs
, configure your application's endpoint to fetch port number from environment
variables. The corresponding variable will be set by systemd on startup:
config :your_app_name, YourAppNameWeb.Endpoint,
http: [:inet6, port: System.get_env("PORT") || 4000],
# ...
Create a config/prod.secret.exs.sample
file for storing secrets that you can later use
to configure your builds:
use Mix.Config
config :your_app_name, YourAppName.Repo,
username: "doadmin",
password: "YOUR_SECURE_PASSWORD",
database: "defaultdb",
hostname: "db.example.com",
port: 25060,
ssl: true,
pool_size: 15
# generate a key with `mix phx.gen.secret 64`
config :your_app_name, YourAppNameWeb.Endpoint,
secret_key_base: "YOUR_SECRET_KEY_BASE"
Confirm that everything compiles by building the app:
mix deps.get
mix deps.compile
mix compile
You should be able to run the app locally with:
# Create development database
mix ecto.create
# Compile assets with production settings
(cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development)
mix phx.server
open http://localhost:4000/
If everything seems to work, you can proceed with deployment just like you did
with the mix-deploy-example
sample application.