Optimizing for Free Hosting — Elixir Deployments

Kubernetes (kidding) — Photo credit Jp Valery on Unsplash

Another way to deploy Elixir apps

I’m going to walk through a deployment strategy that can take you start-to-finish with a full-featured Phoenix web app running on a free-forever Google Cloud Platform Compute Engine instance.

What we’re optimizing for

Free without the compromises (except your time of course)

Overview

Update June 2, 2021

The Details!

1 — Get a domain

2 — Provision an E2-Micro Compute Engine instance

Name: whatever you want
Region: double check https://cloud.google.com/free to make sure the region you want is listed in the Compute Engine section
Series: E2
Machine Type:
e2-micro
Boot Disk:
Operating System: Ubuntu
Version: Ubuntu 20.04 LTS
Boot Disk Type: Standard persistent disk, 30GB
Firewall: allow both HTTP and HTTPS traffic

3 — Make the IP address of the instance static

4 — Add your SSH key

5 — Set up SSH alias

Host my_app
Hostname <IP address from step 3>
User <your local user name>
Host *
AddKeysToAgent yes
UseKeychain yes
IdentityFile ~/.ssh/id_rsa

6 — Enable swap memory on the instance

sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo ‘/swapfile none swap sw 0 0’ | sudo tee -a /etc/fstab

7 — Install Erlang, Elixir, Node, and Postgres

wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb && sudo dpkg -i erlang-solutions_2.0_all.debsudo apt updatesudo apt install esl-erlangsudo apt install elixir
sudo apt install nodejs npm
sudo apt install postgresql postgresql-contrib

8 — Set a secure password for Postgres

sudo -u postgres psql
\password postgres(paste the password you generated and hit enter)(now put \q and hit enter to quit)

9 — Configure Postgres to allow remote connections

Name: whatever you want, something like "database"
Targets:
All instances in the network
Source IP Ranges: 0.0.0.0/0
tcp: Check box, and enter 5432 in field
sudo find / -name "postgresql.conf"
sudo vim <the file path you found>
sudo vim <the file path you found>
host    all             all             0.0.0.0/0               md5
host all all ::/0 md5

10 — Connect to your remote Git repo where your project lives

git clone https://github.com/<YOUR_GITHUB_USERNAME>/my_app
# Ignore secrets files
/config/*.secret.exs
# Ignore local cert files
/tmp/

11 — Get your prod secrets onto the server

scp config/prod.secret.exs my_app:~/my_app/config/
MIX_ENV=prod mix deps.getMIX_ENV=prod mix ecto.create

12 — Configure SSL with SiteEncrypt

{:site_encrypt, "~> 0.4"}

13 — Forward ports

sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 4000
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-port 4040
sudo apt install iptables-persistent
# get the new deps
MIX_ENV=prod mix deps.get
# get the assets ready
npm install --prefix ./assets
npm run deploy --prefix ./assets
MIX_ENV=prod mix phx.digest
# run the app on port 4000 and 4040
MIX_ENV=prod mix phx.server

14 — Build a deployment shell script

vim deploy.sh
#!/bin/bash
set -e
# Update to latest version of code
cd /home/<YOUR_USERNAME>/my_app
git fetch
git reset --hard origin/main
mix deps.get --only prod
# Optional CI steps
mix test
# mix credo --strict (commented out, just another example step)
if System.get_env("CI") do
import_config "test.secret.exs"
end
# Set mix_env so subsequent mix steps don't need to specify it
export MIX_ENV=prod
# Build phase
npm install --prefix ./assets
npm run deploy --prefix ./assets
mix phx.digest
# Identify the currently running release
current_release=$(ls ../releases | sort -nr | head -n 1)
now_in_unix_seconds=$(date +'%s')
if [[ $current_release == '' ]]; then current_release=$now_in_unix_seconds; fi
# Create release
mix release --path ../releases/${now_in_unix_seconds}
# Get the HTTP_PORT variable from the currently running release
source ../releases/${current_release}/releases/0.1.0/env.sh
if [[ $HTTP_PORT == '4000' ]]
then
http=4001
https=4041
old_port=4000
else
http=4000
https=4040
old_port=4001
fi
# Put env vars with the ports to forward to, and set non-conflicting node name
echo "export HTTP_PORT=${http}" >> ../releases/${now_in_unix_seconds}/releases/0.1.0/env.sh
echo "export HTTPS_PORT=${https}" >> ../releases/${now_in_unix_seconds}/releases/0.1.0/env.sh
echo "export RELEASE_NAME=${http}" >> ../releases/${now_in_unix_seconds}/releases/0.1.0/env.sh
# Set the release to the new version
rm ../env_vars || true
touch ../env_vars
echo "RELEASE=${now_in_unix_seconds}" >> ../env_vars
# Run migrations
mix ecto.migrate
# Boot the new version of the app
sudo systemctl start my_app@${http}
# Wait for the new version to boot
until $(curl --output /dev/null --silent --head --fail localhost:${http}); do
echo 'Waiting for app to boot...'
sleep 1
done
# Switch forwarding of ports 443 and 80 to the ones the new app is listening on
sudo iptables -t nat -R PREROUTING 1 -p tcp --dport 80 -j REDIRECT --to-port ${http}
sudo iptables -t nat -R PREROUTING 2 -p tcp --dport 443 -j REDIRECT --to-port ${https}
# Stop the old version
sudo systemctl stop my_app@${old_port}
# Just in case the old version was started by systemd after a server
# reboot, also stop the server_reboot version
sudo systemctl stop my_app@server_reboot
echo 'Deployed!'
chmod u+x deploy.sh

15 — Create systemd services

sudo vim /etc/systemd/system/my_app@.service
sudo systemctl enable my_app@server_reboot
sudo systemctl daemon-reload

16 — Deploy the app

ssh my_app ./deploy.sh

To really see the magic, commit and push a change locally and then run the command again, and you’ll see it gracefully swap over to the new version without any downtime 🎉🎉🎉.

17 — Attach to journald to watch logs, and configure to trim

ssh my_app journalctl -f
ssh my_app journalctl -n 500
ssh my_app journalctl -n 50 -u my_app@4001
Nov 03 21:43:33 instance-1 systemd[1]: Stopped My App.
SystemMaxUse=4G
sudo systemctl restart systemd-journald.service

You made it! 🙌

Bonus 1: secure your data against the possibility of eventual hardware failure by creating another Google account with a free instance and set up automated backups using cron

sudo apt install postgresql postgresql-contrib
#hostname:port:database:username:password
<server IP address>:5432:my_app:postgres:<postgres password>
pg_dump -U postgres -h <server IP address> -p 5432 my_app > ~/my_app/my_app_hourly.bak

Bonus 2: Rollback script

chmod u+x rollback.sh

Elixir dev building for the web with Phoenix

Elixir dev building for the web with Phoenix