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:

  1. can be deployed in a single VM
  2. host a number of separate web apps, connecting to local Postgres service
  3. apps with different subdomains
  4. 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 to proxy/ -> then on to landing_page_web
  • user requests subdomain.domain.com -> goes to proxy/ -> then on to kanban_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) to A 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, and postgres 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 with dig +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 of Proxy.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 and 443
  • 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())