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
) toA
record
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_tarpit
port value - allocate swap
- reduce
journald
log size - install
ufw
,fail2ban
,ssh_tarpit
,authbind
,certbot
,git
, andpostgres
server
#!/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
A
records 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.Endpoint
only 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_origin
ofProxy.Endpoint
has 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
80
and443
- 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=always
RestartSec=5
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
[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
Running mix ecto.create
inside shell (source):
alias NappyWeb.Repo
Repo.__adapter__().storage_up(Repo.config())