1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130 | #!/usr/bin/env bash
#
# Prepare a fresh Ubuntu server to host static sites.
#
# ./deploy/bootstrap.sh
#
# Run once per server. Safe to run again — every step checks before acting, so
# re-running it after an OS upgrade or to repair something changes only what is
# actually missing.
#
# What it does:
# · installs Docker from Docker's own APT repository (signed, verifiable)
# · closes the firewall to everything except SSH, HTTP and HTTPS
# · creates /opt/web, the layout every site on this box deploys into
# · starts Caddy, which owns ports 80 and 443 and gets TLS certificates
# automatically
#
# This box is deliberately not the box the Agora API runs on. Static sites and
# a production API have different appetites for risk, downtime and reboots, and
# nothing here should ever be able to affect that service.
set -euo pipefail
SERVER="${ERATIC_SERVER:-root@178.104.116.235}"
SSH_KEY="${ERATIC_SSH_KEY:-$HOME/.ssh/id_ed25519_eratic}"
REMOTE_ROOT="${ERATIC_REMOTE_ROOT:-/opt/web}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
ok() { printf '\033[32m✓\033[0m %s\n' "$*"; }
fail() { printf '\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
remote() { ssh -o BatchMode=yes -o ConnectTimeout=20 -i "$SSH_KEY" "$SERVER" "$@"; }
[[ -f "$SSH_KEY" ]] || fail "SSH key not found at $SSH_KEY"
remote true 2>/dev/null || fail "cannot reach $SERVER over SSH"
ok "server reachable"
bold "System packages"
remote 'bash -euo pipefail -s' <<'REMOTE'
export DEBIAN_FRONTEND=noninteractive
if ! command -v docker >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg >/dev/null
# Docker's official repository, verified by its own signing key. Preferred
# over piping get.docker.com into a shell: apt checks the signature on every
# future update too, not just today.
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null
systemctl enable --now docker >/dev/null
echo "docker installed"
else
echo "docker already present"
fi
# Security updates apply themselves. On a box that mostly sits there serving
# files, the realistic threat is an unpatched CVE, not a clever attacker.
apt-get install -y -qq unattended-upgrades >/dev/null
systemctl enable --now unattended-upgrades >/dev/null 2>&1 || true
REMOTE
ok "docker + unattended-upgrades"
bold "Firewall"
remote 'bash -euo pipefail -s' <<'REMOTE'
export DEBIAN_FRONTEND=noninteractive
command -v ufw >/dev/null || apt-get install -y -qq ufw >/dev/null
# Order matters: allow SSH before enabling, or enabling the firewall ends the
# session that is configuring it.
ufw allow 22/tcp >/dev/null
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw allow 443/udp >/dev/null # HTTP/3
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw --force enable >/dev/null
ufw status numbered | head -12
REMOTE
ok "firewall: 22, 80, 443 only"
bold "Layout"
remote "mkdir -p '$REMOTE_ROOT'/caddy/etc/conf.d '$REMOTE_ROOT'/sites"
# The main Caddyfile ends with `import conf.d/*.caddy`. On a box where no
# project has deployed yet that glob matches nothing, which Caddy treats as an
# error and refuses to start. This placeholder guarantees at least one match
# and one site. Port 8080 is not published by the compose file, so it is
# reachable from nowhere and cannot shadow a real hostname on 80 or 443.
remote "cat > '$REMOTE_ROOT/caddy/etc/conf.d/00-placeholder.caddy'" <<'PLACEHOLDER'
# Keeps `import conf.d/*.caddy` valid on a server with no sites deployed yet.
# Harmless to leave in place once real sites exist.
:8080 {
respond "no site configured" 404
}
PLACEHOLDER
ok "$REMOTE_ROOT ready"
bold "Edge server config"
rsync -az -e "ssh -o BatchMode=yes -i $SSH_KEY" \
"$REPO_ROOT/deploy/server/Caddyfile" \
"$SERVER:$REMOTE_ROOT/caddy/etc/"
rsync -az -e "ssh -o BatchMode=yes -i $SSH_KEY" \
"$REPO_ROOT/deploy/server/docker-compose.yml" \
"$SERVER:$REMOTE_ROOT/caddy/"
ok "caddy config uploaded"
bold "Start Caddy"
remote "cd '$REMOTE_ROOT/caddy' && docker compose up -d --quiet-pull" >/dev/null 2>&1 \
|| fail "caddy failed to start — check: ssh $SERVER 'cd $REMOTE_ROOT/caddy && docker compose logs'"
sleep 3
STATUS="$(remote "docker ps --filter name=web-caddy --format '{{.Status}}'")"
[[ -n "$STATUS" ]] || fail "caddy container is not running"
ok "caddy running ($STATUS)"
bold "Done"
echo " Deploy a site with: npm run deploy"
|