Runbook for provisioning the Frostbyte production VM. Written against the current production box so it can be followed verbatim to rebuild from scratch.
- Host: Hetzner Cloud VM (2 vCPU, Ubuntu 24.04 LTS)
- IP:
91.107.210.218 - Hostname:
frostbyte - DNS:
frostbyte.tvA record →91.107.210.218 - SSH key:
~/.ssh/frostcrypton the admin workstation - Admin user:
deploy(passwordless sudo, key-only SSH) - App dir:
/opt/frostbyte/{releases,shared}
Boring, reproducible, runs Elixir releases directly under systemd. No Docker, no orchestration, no config management tool. Everything in one shell script you can diff against the running box.
- Order a fresh Ubuntu 24.04 VM at Hetzner with your SSH public key in the
rootauthorized_keys. - Point an A record at the VM's IPv4 address.
- Run Phase 1 (system hardening) as root via SSH.
- Reconnect as
deployand run Phase 2 (Erlang + Elixir via asdf). - Drop in the Caddyfile and the systemd unit once the first release exists.
Runs once on first boot. Idempotent, safe to re-run.
ssh -i ~/.ssh/frostcrypt root@<new-ip> 'bash -s' <<'EOF'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
timedatectl set-timezone UTC
timedatectl set-ntp true
# 2 GB swap, low swappiness
if [ ! -f /swapfile ]; then
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf
sysctl -p /etc/sysctl.d/99-swappiness.conf
fi
apt-get update -qq
apt-get -y -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade
apt-get install -y -qq \
build-essential autoconf m4 libncurses-dev libssl-dev libreadline-dev \
libyaml-dev libxml2-dev libxslt1-dev unzip git curl ca-certificates \
ufw fail2ban unattended-upgrades \
debian-keyring debian-archive-keyring apt-transport-https gnupg \
libssh-dev libodbc2 unixodbc-dev flex bison
# deploy user
if ! id deploy &>/dev/null; then
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
fi
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy
chmod 440 /etc/sudoers.d/deploy
visudo -cf /etc/sudoers.d/deploy
# sshd hardening
cat > /etc/ssh/sshd_config.d/99-frostbyte.conf <<'SSHD'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 60
ClientAliveCountMax 3
SSHD
sshd -t
systemctl reload ssh
# firewall
ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
echo "y" | ufw enable
# fail2ban
cat > /etc/fail2ban/jail.local <<'F2B'
[sshd]
enabled = true
port = 22
maxretry = 3
bantime = 3600
findtime = 600
F2B
systemctl enable --now fail2ban
# unattended-upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'AUTO'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
AUTO
systemctl enable --now unattended-upgrades
# caddy apt repo
if [ ! -f /etc/apt/sources.list.d/caddy-stable.list ]; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq
fi
apt-get install -y -qq caddy
install -d -m 755 -o deploy -g deploy /opt/frostbyte
install -d -m 755 -o deploy -g deploy /opt/frostbyte/releases
install -d -m 755 -o deploy -g deploy /opt/frostbyte/shared
install -d -m 755 -o deploy -g deploy /opt/frostbyte/shared/log
# asdf 0.18.1
ASDF_VERSION=v0.18.1
if [ ! -x /usr/local/bin/asdf ] || ! /usr/local/bin/asdf --version 2>/dev/null | grep -q "0.18.1"; then
curl -sL "https://github.com/asdf-vm/asdf/releases/download/${ASDF_VERSION}/asdf-${ASDF_VERSION}-linux-amd64.tar.gz" -o /tmp/asdf.tar.gz
tar -xzf /tmp/asdf.tar.gz -C /usr/local/bin/
chmod +x /usr/local/bin/asdf
rm /tmp/asdf.tar.gz
fi
# asdf shell integration for deploy
sudo -u deploy bash <<'DEPLOY'
cat > ~/.bashrc.asdf <<'RC'
export ASDF_DATA_DIR="$HOME/.asdf"
export PATH="$ASDF_DATA_DIR/shims:$HOME/.local/bin:$PATH"
RC
grep -qxF 'source $HOME/.bashrc.asdf' ~/.bashrc || echo 'source $HOME/.bashrc.asdf' >> ~/.bashrc
touch ~/.profile
grep -qxF 'source $HOME/.bashrc.asdf' ~/.profile || echo 'source $HOME/.bashrc.asdf' >> ~/.profile
DEPLOY
echo "phase 1 done"
EOFAfter this completes, root login is disabled. All further steps run as
deploy.
Compiling Erlang from source takes roughly 10-15 minutes on a 2 vCPU box.
Run it under systemd-run so it survives SSH drops and logs to the journal.
ssh -i ~/.ssh/frostcrypt deploy@<ip> 'bash -s' <<'EOF'
cat > /home/deploy/build-beam.sh <<'BUILD'
#!/bin/bash
set -euo pipefail
export HOME=/home/deploy
export ASDF_DATA_DIR="$HOME/.asdf"
export PATH="$ASDF_DATA_DIR/shims:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
export KERL_CONFIGURE_OPTIONS="--without-wx --without-debugger --without-observer --without-et --without-javac --without-jinterface --enable-threads --enable-smp-support --enable-kernel-poll"
export KERL_BUILD_DOCS=no
export KERL_INSTALL_MANPAGES=no
export KERL_INSTALL_HTMLDOCS=no
export MAKEFLAGS="-j$(nproc)"
cd "$HOME"
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git || true
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git || true
ERLANG_VERSION=$(asdf latest erlang 28)
asdf install erlang "$ERLANG_VERSION"
asdf install elixir 1.19.5-otp-28
asdf set -u erlang "$ERLANG_VERSION"
asdf set -u elixir 1.19.5-otp-28
BUILD
chmod +x /home/deploy/build-beam.sh
sudo systemd-run \
--unit=frostbyte-build \
--uid=deploy --gid=deploy \
--working-directory=/home/deploy \
--setenv=HOME=/home/deploy \
/bin/bash /home/deploy/build-beam.sh
# follow: sudo journalctl -u frostbyte-build -f
EOFWatch progress:
ssh deploy@<ip> sudo journalctl -u frostbyte-build -fWhen you see Elixir 1.19.5 (compiled with Erlang/OTP 28) the box is ready.
| component | version |
|---|---|
| Ubuntu | 24.04 LTS |
| Erlang | OTP 28.4.2 |
| Elixir | 1.19.5-otp-28 |
| asdf | 0.18.1 |
| Caddy | 2.11.2 |
The production Caddyfile splits the apex (static landing) from the realtime sync subdomain so each surface has its own DNS and caching profile.
frostbyte.tv {
encode zstd gzip
root * /var/www/frostbyte
file_server
}
ws.frostbyte.tv {
reverse_proxy localhost:4000
}DNS: both frostbyte.tv and ws.frostbyte.tv are proxied through
Cloudflare (orange cloud). The CNAME for ws points at the apex.
Reload after edits: sudo systemctl reload caddy. Caddy handles Let's
Encrypt certificate issuance and renewal automatically.
Landing page contents live at /var/www/frostbyte. Deployed via:
cd landing && tar -cf - . | ssh deploy@frostbyte.tv '
set -e
sudo rm -rf /var/www/frostbyte
sudo mkdir -p /var/www/frostbyte
sudo tar -C /var/www/frostbyte -xf -
sudo chown -R root:root /var/www/frostbyte
sudo find /var/www/frostbyte -type d -exec chmod 755 {} \;
sudo find /var/www/frostbyte -type f -exec chmod 644 {} \;
'Will live at /etc/systemd/system/frostbyte.service. Template:
[Unit]
Description=Frostbyte sync server
After=network.target
[Service]
Type=exec
User=deploy
Group=deploy
Environment=HOME=/home/deploy
Environment=LANG=en_US.UTF-8
Environment=MIX_ENV=prod
WorkingDirectory=/opt/frostbyte/current
ExecStart=/opt/frostbyte/current/bin/frostbyte start
ExecStop=/opt/frostbyte/current/bin/frostbyte stop
Restart=on-failure
RestartSec=5
KillMode=mixed
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetDeploys will:
- Build a release locally (
MIX_ENV=prod mix release) rsyncto/opt/frostbyte/releases/<timestamp>/- Swap the
/opt/frostbyte/currentsymlink sudo systemctl restart frostbyte
No downtime goals for v1 (restart takes under a second, users reconnect automatically via Phoenix channel reconnection).