Observations of an umbrella project deployed in VPS
TLDR;
- As of this writing, we're using Phoenix v1.7.12
- Not ideal for production
:(. - Deployed an umbrella app that contains:
- an app communicating to local Postgres service.
- an app acting as reverse proxy for each sites (e.g.
subdomain.domain.co&domain.co). - an app acting as landing page (no Ecto).
- Data store (Postgres) is installed locally.
- Lots of manual setup/scripting in the VM, didn't use tools like Ansible.
- Used systemd for supervising the release binary during machine restarts.
- Used certbot and renewal hooks
- Used CI/CD tool like Github action
Disclaimer
All scripts and example codes written here do not conform with the words "best practices" nor to be taken as step-by-step tutorial, since this will change depending on use cases. Also treat this as a semi-living document.
Rationale
I wanted to know if a simple umbrella app:
- can be deployed in a single VM
- host a number of separate web apps, connecting to local Postgres service
- apps with different subdomains
- not using external webserver (e.g.
NginX)
projects_umbrella/
├── ...
├── apps/
│ ├── projects/
│ ├── projects_web/ <- domain.co
│ ├── my_app/
│ ├── my_app__web/ <- subdomain.domain.co
│ └── proxy/
└── ...
So the flow of request/response is:
- user requests
domain.com-> goes toproxy/-> then on tolanding_page_web - user requests
subdomain.domain.com-> goes toproxy/-> then on tokanban_web
All requests goes to proxy, then it routes to the correct app (Phoenix endpoint).
Prepare
Get the following:
- VPS VM instance (e.g. Ubuntu distro)
- Domain name (e.g. from Porkbun), then point the instance (
ipv4) toArecord
Init and user runtime scripts
Create init script
If the VPS platform doesn't include a "script setup" option for boot, might as well run this command manually.
The init script does the following:
- set
ssh_tarpitport value - allocate swap
- reduce
journaldlog size - install
ufw,fail2ban,ssh_tarpit,authbind,certbot,git, andpostgresserver
#!/usr/bin/env bash
# NOTE: this init script is tested using Debian
ssh_tarpit=52698
sudo apt update -y
# allocate swap
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
cp /etc/fstab /etc/fstab.bak
echo '/swapfile none swap sw 0 0' | tee -a /etc/fstab
sysctl vm.swappiness=10
echo vm.swappiness=10 >> /etc/sysctl.conf
echo vm.vfs_cache_pressure=50 >> /etc/sysctl.conf
# limit journalctl
sed -i -e 's/#Compress=yes/Compress=yes/g' /etc/systemd/journald.conf
sed -i -e 's/#SystemMaxUse=/SystemMaxUse=100M/g' /etc/systemd/journald.conf
sed -i -e 's/#SystemMaxFileSize=/SystemMaxFileSize=50M/g' /etc/systemd/journald.conf
systemctl restart systemd-journald
# backup sshd config
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
# enable port 80, 443 and ssh tarpit
ufw allow https
ufw allow http
ufw allow $ssh_tarpit/tcp
# install authbind (using ports below 1024 for non-root users)
# install packages
apt install authbind snapd fail2ban build-essential libc6-dev git -y
# fail2ban
cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sed -i "s/#Port 22/Port $ssh_tarpit/g" /etc/ssh/sshd_config
systemctl restart sshd
ufw --force enable
# use endlessh tarpit
git clone https://github.com/do-community/endlessh
cd endlessh
make
# persist endlessh tarpit after session ends
mv ./endlessh /usr/local/bin/
cp util/endlessh.service /etc/systemd/system/
sed -i "s/#AmbientCapabilities=CAP_NET_BIND_SERVICE/AmbientCapabilities=CAP_NET_BIND_SERVICE/g" /etc/systemd/system/endlessh.service
sed -i "s/PrivateUsers=true/#PrivateUsers=true/g" /etc/systemd/system/endlessh.service
sed -i "s/InaccessiblePaths=\/run \/var/#InaccessiblePaths=\/run \/var/g" /etc/systemd/system/endlessh.service
setcap 'cap_net_bind_service=+ep' /usr/local/bin/endlessh
mkdir /etc/endlessh
echo "Port 22" >> /etc/endlessh/config
sudo systemctl daemon-reload
systemctl --now enable endlessh
# to check endlessh tarpit status
# journalctl -u endlessh.service -b
# install certbot
snap install core
snap install --classic certbot
ln -s /snap/bin/certbot /usr/bin/certbot
# install postgres
apt install -y postgresql postgresql-contrib
Test ssh connectivity
Add entry to your local machine
# ~/.ssh/config
Host umbrella
HostName IP_ADDRESS_OF_VM
User root
PreferredAuthentications publickey
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519
RemoteForward 52698 localhost:52698
Port 52698
Then test it
ssh umbrella -vvv
If it won't connect, one possible cause is the ports don't match with the allowed ssh_tarpit=xxxxxx (see "Create init script" section).
Setup Postgres
After successful ssh login, assuming user is root.
Change user postgres password
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'your password';"
Access psql shell
sudo -u postgres psql
(Optional) It's up to you if you want to create another user
CREATE USER username WITH ENCRYPTED PASSWORD 'user_password';
Create the database + assign to user.
Note: The database name is based on the app that depends on Ecto. So if you have 2 different apps and ecto, that's 2 database names.
CREATE DATABASE database_name;
GRANT ALL PRIVILEGES ON DATABASE database_name TO username;
Additional Info:
Format for database url that will be used for our Phoenix app:
postgres://username:password@localhost:port/database_name
Note: be very consistent and cautious with database creds.
Test psql shell connecting to a db
psql postgres://username:password@localhost:port/database_name
Importing a db
psql -U username -h localhost -p 5432 database_name < path/to/db.sql
Tune Postgres config
Note: as of this writing, we're using postgres v14 and ubuntu jammy (22.04)
postgresql.conf file can be found in:
sudo -u postgres psql -c "show config_file;"
Update values below in /etc/postgresql/<VERSION>/main/postgresql.conf
# This config is taken from https://pgtune.leopard.in.ua
# DB Version: 14
# OS Type: linux
# DB Type: web
# Total Memory (RAM): 1 GB
# CPUs num: 1
# Data Storage: ssd
max_connections = 200
shared_buffers = 256MB
effective_cache_size = 768MB
maintenance_work_mem = 64MB
checkpoint_completion_target = 0.9
wal_buffers = 7864kB
default_statistics_target = 100
random_page_cost = 1.1
effective_io_concurrency = 200
work_mem = 655kB
huge_pages = off
min_wal_size = 1GB
max_wal_size = 4GB
Then after updating it, restart the service:
systemctl restart postgresql
Validate conf file for errors:
select sourcefile, name,sourceline,error from pg_file_settings where error is not null;
Certbot
Note: before running the command below, point the ipv4 of the instance to
Arecords first and wait for dns propagation withdig +short DOMAIN_NAME.
sudo certbot certonly --noninteractive --standalone --domain domain.co --email user@email.co --agree-tos
sudo certbot certonly --noninteractive --standalone --domain subdomain.domain.co --email user@email.co --agree-tos
An example umbrella app
Create umbrella app
In this example, I didn't include Ecto.
# change umbrella_name
mix phx.new --umbrella projects --no-ecto
add proxy
cd apps/ && mix phx.new.web proxy --install --no-ecto --no-assets --no-esbuild --no-tailwind --no-gettext --no-html --no-live --no-mailer --no-dashboard
add another app + ecto
cd .. && mix phx.new.web my_app_web --install
mix phx.new.ecto my_app --install
in the root folder, edit mix.exs
Add the releases key inside def project do function:
def project do
[
apps_path: "apps",
version: "0.1.0",
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
# add this code
releases: [
projects_umbrella: [
applications: [
proxy: :permanent,
projects: :permanent,
projects_web: :permanent,
my_app: :permanent,
my_app_web: :permanent
]
]
]
]
end
asset build for web apps
cd projects_web && MIX_ENV=prod assets.deploy && cd ..
cd my_app_web && MIX_ENV=prod assets.deploy && cd ..
generate release files for each app
cd proxy && MIX_ENV=prod mix release && cd ..
cd projects && MIX_ENV=prod mix release && cd ..
cd projects_web && MIX_ENV=prod mix release && cd ..
cd my_app && MIX_ENV=prod mix release && cd ..
cd my_app_web && MIX_ENV=prod mix release && cd ../..
Tweak configs
Open config/runtime.exs then make some with endpoint, inside if config_env() == :prod do clause:
config :proxy, Proxy.Endpoint,
force_ssl: [hsts: true],
url: [host: System.get_env("PHX_HOST") || "example.com", port: 443, scheme: "https"],
https: [
port: 443,
cipher_suite: :strong,
keyfile: System.get_env("SSL_KEY_PATH"),
certfile: System.get_env("SSL_CERT_PATH")
],
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000")
],
secret_key_base: secret_key_base,
server: true
config :my_app_web, MyAppWeb.Endpoint,
secret_key_base: secret_key_base,
server: false
config :projects_web, ProjectsWeb.Endpoint,
secret_key_base: secret_key_base,
server: false
Note:
Proxy.Endpointonly has the server enabled (server: true) and ssl
Change config/prod.exs the following, something like this:
import Config
config :proxy, Proxy.Endpoint,
url: [host: "domain", port: 80],
check_origin: ["https://domain.co", "https://subdomain.domain.co"]
config :my_app_web, WordleWeb.Endpoint,
url: [host: "subdomain.domain.co", port: 80],
cache_static_manifest: "priv/static/cache_manifest.json",
check_origin: ["https://subdomain.domain.co"]
config :projects_web, ProjectsWeb.Endpoint,
url: [host: "domain.co", port: 80],
cache_static_manifest: "priv/static/cache_manifest.json",
check_origin: ["https://domain.co"]
...
Note:
check_originofProxy.Endpointhas 2 domains, in which it it handles the endpoints of since it's reverse proxying the apps.
implement reverse proxy
Replace the contents of apps/proxy/lib/proxy/endpoint.ex to this:
defmodule Proxy.Endpoint do
use Phoenix.Endpoint, otp_app: :proxy
@subdomains %{
"subdomain" => MyAppWeb.Endpoint
}
@default_host ProjectsWeb.Endpoint
def init(opts), do: opts
def call(conn, _) do
with subdomain <- String.replace(conn.host, ~r/\.?domain.*$/, ""),
endpoint <- Map.get(@subdomains, subdomain, @default_host) do
endpoint.call(conn, endpoint.init(nil))
end
end
end
It checks if the hostname has subdomain subdomain name and serves the
specific endpoint (MyAppWeb.Endpoint), otherwise it defaults to
Projects.Endpoint.
testing
If for some reason when running mix test in the root project throws
errors like:
** (UndefinedFunctionError) function MyAppWeb.Repo.get_dynamic_repo/0 is undefined (module MyAppWeb.Repo is not available)
It means it should be MyApp.Repo instead. So update apps/my_app_web/test/test_helper.exs
to something like this:
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual
Another error:
** (UndefinedFunctionError) function MyAppWeb.DataCase.setup_sandbox/1 is undefined (module MyAppWeb.DataCase is not available)
Update apps/my_app_web/test/support/conn_case.ex it to something like:
setup tags do
MyApp.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
Then proceed to run mix test again.
Create post-install script
The script below does the following:
- Create ssh deploy key to be used for Github action
- Allow non-root users to use ports
80and443 - Create env file
- Create systemd service file for handling umbrella during reboot
- Copy certfiles from
/etc/dir to home directory - Create certbot renewal hook
Name the script ~/postinstall.sh
#!/usr/bin/env bash
domain="domain.co"
subdomain="subdomain.domain.co"
app_name="projects_umbrella"
certbot_email="email@domain.co"
database_url="postgres://user:password@host/database_name"
secret_key_base="paste here output of mix phx.gen.secret"
ssh-keygen -t ed25519 -C $certbot_email -q -f "$HOME/.ssh/id_ed25519" -N ""
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
# restricted usage of ports below 1024 for non-root users
# so we're using authbind for a specific user running the app
sudo touch /etc/authbind/byport/80
sudo touch /etc/authbind/byport/443
sudo chmod 500 /etc/authbind/byport/80
sudo chmod 500 /etc/authbind/byport/443
sudo chown $USER /etc/authbind/byport/80
sudo chown $USER /etc/authbind/byport/443
# create environment file
sudo bash -c "cat >> $HOME/$app_name.env" << EOL
HOME="$HOME/$app_name"
RELX_REPLACE_OS_VARS=true
SECRET_KEY_BASE=$secret_key_base
SSL_CERT_PATH="$HOME/ssl_cert/$domain/cert.pem"
SSL_KEY_PATH="$HOME/ssl_cert/$domain/privkey.pem"
SSL_CACERT_PATH="$HOME/ssl_cert/$domain/chain.pem"
SUB_SSL_CERT_PATH="$HOME/ssl_cert/$subdomain/cert.pem"
SUB_SSL_KEY_PATH="$HOME/ssl_cert/$subdomain/privkey.pem"
SUB_SSL_CACERT_PATH="$HOME/ssl_cert/$subdomain/chain.pem"
DATABASE_URL=$database_url
PHX_SERVER=true
MIX_ENV=prod
PHX_HOST=$domain
PORT=80
ELIXIR_ERL_OPTIONS="+fnu"
EOL
sudo chmod 644 "$HOME/$app_name.env"
sudo chown $USER:$USER "$HOME/$app_name.env"
# create systemd service for the app
sudo bash -c "cat >> /etc/systemd/system/$app_name.service" << EOL
[Unit]
Description=App for $app_name
After=network.target
Requires=network.target
[Service]
RemainAfterExit=yes
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=$app_name
WorkingDirectory=$HOME/$app_name
User=$USER
Group=$USER
Restart=on-failure
EnvironmentFile=$HOME/$app_name.env
ExecStart=authbind --deep $HOME/$app_name/bin/$app_name start
ExecStop=authbind --deep $HOME/$app_name/bin/$app_name stop
KillMode=process
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
EOL
sudo chmod 644 "/etc/systemd/system/$app_name.service"
sudo systemctl daemon-reload
sudo systemctl enable $app_name
sudo systemctl stop $app_name
# create renewal hooks (deploy)
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/deploy/$domain.sh" << EOL
#!/bin/bash
domain="$domain"
archive_cert_dir="/etc/letsencrypt/archive/\$domain"
user_cert_dir="\$HOME/ssl_cert/\$domain"
mkdir -p \$archive_cert_dir
mkdir -p \$user_cert_dir
for file in \$(sudo ls -t \$archive_cert_dir | head -n 4); do
new_file=\$(echo \$file | sed 's/[0-9]*//g')
sudo cp "\$archive_cert_dir/$file" "\$user_cert_dir/\$new_file"
unset \$file
unset \$new_file
done
sudo chown -R \$USER:\$USER "\$HOME/ssl_cert"
sudo chmod -R 755 "\$HOME/ssl_cert"
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/deploy/$domain.sh"
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/deploy/$subdomain.sh" << EOL
#!/bin/bash
subdomain="$subdomain"
archive_cert_dir="/etc/letsencrypt/archive/\$subdomain"
user_cert_dir="\$HOME/ssl_cert/\$subdomain"
mkdir -p \$archive_cert_dir
mkdir -p \$user_cert_dir
for file in \$(sudo ls -t \$archive_cert_dir | head -n 4); do
new_file=\$(echo \$file | sed 's/[0-9]*//g')
sudo cp "\$archive_cert_dir/$file" "\$user_cert_dir/\$new_file"
unset \$file
unset \$new_file
done
sudo chown -R \$USER:\$USER "\$HOME/ssl_cert"
sudo chmod -R 755 "\$HOME/ssl_cert"
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/deploy/$subdomain.sh"
# create renewal pre-hooks
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/pre/$domain.sh" << EOL
#!/bin/bash
webapp="$app_name"
sudo systemctl stop \$webapp
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/pre/$domain.sh"
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/pre/$subdomain.sh" << EOL
#!/bin/bash
webapp="$app_name"
sudo systemctl stop \$webapp
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/pre/$subdomain.sh"
# create renewal post-hooks
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/post/$domain.sh" << EOL
#!/bin/bash
webapp="$app_name"
sudo systemctl start \$webapp
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/post/$domain.sh"
sudo bash -c "cat >> /etc/letsencrypt/renewal-hooks/post/$subdomain.sh" << EOL
#!/bin/bash
webapp="$app_name"
sudo systemctl start \$webapp
EOL
sudo chmod 750 "/etc/letsencrypt/renewal-hooks/post/$subdomain.sh"
run the script:
chmod +x ~/postinstall.sh
./postinstall.sh
Once the commands ran successfully, remove the script:
rm ~/postinstall.sh
Then:
domain="domain.co"
archive_cert_dir="/etc/letsencrypt/archive/$domain"
user_cert_dir="$HOME/ssl_cert/$domain"
mkdir -p $user_cert_dir
for file in $(sudo ls -t $archive_cert_dir | head -n 4); do
new_file=$(echo $file | sed 's/[0-9]*//g')
sudo cp "$archive_cert_dir/$file" "$user_cert_dir/$new_file"
unset $file
unset $new_file
done
subdomain="subdomain.domain.co"
archive_cert_dir="/etc/letsencrypt/archive/$subdomain"
user_cert_dir="$HOME/ssl_cert/$subdomain"
mkdir -p $user_cert_dir
for file in $(sudo ls -t $archive_cert_dir | head -n 4); do
new_file=$(echo $file | sed 's/[0-9]*//g')
sudo cp "$archive_cert_dir/$file" "$user_cert_dir/$new_file"
unset $file
unset $new_file
done
sudo chown -R $USER:$USER "$HOME/ssl_cert"
sudo chmod -R 755 "$HOME/ssl_cert"
Create Github repository secrets
Go to https://github.com/ORG_OR_USERNAME/UMBRELLA_REPO/settings/secrets/actions
name: HOST
value: domain.co
name: PORT
value: port being used by ssh_tarpit (e.g. 52698)
name: SSHKEY
value: PASTE THE RESULT from ~/.ssh/id_ed25519
name: USERNAME
value: the username being used in ssh
name: CODECOV_TOKEN
value: api token taken from https://app.codecov.io/account/gh/ORG_OR_USERNAME/access
Github action workflows for ci/cd
continuous integration
# save to .github/workflows/ci.yml
name: continuous integration
on:
workflow_call:
pull_request:
push:
jobs:
# https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
# Workflows that would otherwise be triggered using `on: push` or
# `on: pull_request` won't be triggered if you add any of the
# following strings to the commit message in a push, or the HEAD
# commit of a pull request:
# - [skip ci]
# - [ci skip]
# - [no ci]
# - [skip actions]
# - [actions skip]
test:
runs-on: ubuntu-latest
env:
MIX_ENV: test
services:
db:
env:
POSTGRES_PASSWORD: postgres
image: 'postgres:14-alpine'
ports:
- '5432:5432'
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s
--health-retries 5
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Elixir/OTP
uses: erlef/setup-beam@v1
with:
otp-version: '25'
elixir-version: 1.15.x
- name: Cache deps and build
uses: actions/cache@v4
with:
path: |
deps
_build
key: "${{ runner.os }}-mixtest-${{ hashFiles('mix.lock') }}"
restore-keys: |
${{ runner.os }}-mixtest-
- name: Install Dependencies
run: |
mix local.rebar --if-missing
mix local.hex --if-missing
mix deps.get
- name: Check format and style guide
run: |
mix format --check-formatted
continuous deployment
# save this to .github/workflows/cd.yml
name: continuous deployment
on:
push:
branches:
- staging
jobs:
ci_workflow:
uses: ./.github/workflows/ci.yml
deploy:
runs-on: ubuntu-latest
needs: [ci_workflow]
env:
MIX_ENV: prod
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Elixir/OTP
uses: erlef/setup-beam@v1
with:
otp-version: '25'
elixir-version: 1.15.x
- name: Cache deps and build
uses: actions/cache@v3
with:
path: |
deps
_build
key: "${{ runner.os }}-mixprod-${{ hashFiles('mix.lock') }}"
restore-keys: |
${{ runner.os }}-mixprod-
- name: Install Dependencies
run: |
mix local.rebar --if-missing
mix local.hex --if-missing
mix deps.get --only prod
mix format --check-formatted
- name: Compile
run: mix compile --return-errors
- name: Prepare assets
run: mix assets.deploy
- name: Build release
run: mix release --force --overwrite
- name: Copy repository contents via scp
uses: appleboy/scp-action@master
with:
host: '${{ secrets.HOST }}'
username: '${{ secrets.USERNAME }}'
port: '${{ secrets.PORT }}'
key: '${{ secrets.SSHKEY }}'
source: '_build/prod/rel'
target: '.'
timeout: 180s
- name: restarting systemd
uses: appleboy/ssh-action@master
with:
host: '${{ secrets.HOST }}'
username: '${{ secrets.USERNAME }}'
port: '${{ secrets.PORT }}'
key: '${{ secrets.SSHKEY }}'
timeout: 180s
script: |
cp -r _build/prod/rel/* ./
./my_app_web/bin/migrate
rm -rf _build
sudo systemctl stop projects_umbrella
sudo systemctl start projects_umbrella
sudo systemctl status projects_umbrella
Make PR for updating dependencies.
Save this to .github/workflows/dependabot.yml
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: 'mix' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'weekly'
labels:
- 'hex'
- 'dependencies'
# use the code below if you have npm dependencies
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/apps/my_app_web/assets' # update the directory
schedule:
interval: 'weekly'
labels:
- 'npm'
- 'dependencies'
# As of this writing, I'm not sure how to "glob" patterns in order
# to recursively get npm dependencies, but here's other way:
# https://github.com/dependabot/dependabot-core/issues/2824#issuecomment-741693711
# TLDR; add each directory
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/apps/projects_web/assets' # update the directory
schedule:
interval: 'weekly'
labels:
- 'npm'
- 'dependencies'
Commit and push changes to repo, then check logs.
Misc
View logs:
tail -f /var/log/syslog | grep projects_umbrella"
Running mix ecto.create inside shell (source):
alias NappyWeb.Repo
Repo.__adapter__().storage_up(Repo.config())