From 5a3068669631e622fca4c243207034ea0dbeb5f2 Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 24 Apr 2026 02:45:35 -0400 Subject: [PATCH 1/4] feat: add VPS baseline provisioning assets - add bootstrap, swap, and certificate scripts for the Linode VPS - add nginx configs for api, station, and bot subdomains - document the baseline VPS setup flow under infra --- infra/README.md | 28 +++++++++++ infra/nginx/api.drdnt.org.conf | 13 ++++++ infra/nginx/bot.drdnt.org.conf | 14 ++++++ infra/nginx/station.drdnt.org.conf | 13 ++++++ infra/scripts/bootstrap-vps.sh | 75 ++++++++++++++++++++++++++++++ infra/scripts/issue-certs.sh | 14 ++++++ infra/scripts/setup-swap.sh | 27 +++++++++++ 7 files changed, 184 insertions(+) create mode 100644 infra/nginx/api.drdnt.org.conf create mode 100644 infra/nginx/bot.drdnt.org.conf create mode 100644 infra/nginx/station.drdnt.org.conf create mode 100755 infra/scripts/bootstrap-vps.sh create mode 100755 infra/scripts/issue-certs.sh create mode 100755 infra/scripts/setup-swap.sh diff --git a/infra/README.md b/infra/README.md index 701528f..e560fb8 100644 --- a/infra/README.md +++ b/infra/README.md @@ -40,3 +40,31 @@ Review `terraform plan` before every apply. The existing VPS must be imported ra - `ssh_public_key`: deploy SSH public key Keep real values in `infra/terraform/terraform.tfvars`, which is gitignored. + +## VPS Baseline + +Issue `#107` builds on the Terraform layer after DNS from issue `#106` has propagated. + +1. Copy the repository or the `infra/` directory to the VPS. +2. Run `infra/scripts/bootstrap-vps.sh` as `root`. +3. Install the deploy key for the `deploy` user when prompted by the script. +4. Review and install the Nginx site configs from `infra/nginx/`. +5. Run `infra/scripts/issue-certs.sh` to request the initial certificates. +6. Verify renewal with `certbot renew --dry-run`. + +After setup, the VPS layout should look like: + +```text +/opt/station/ + docker-compose.prod.yml + .env.production + logs/ +``` + +The scripts here are designed to establish the baseline only: + +- `infra/scripts/bootstrap-vps.sh`: installs Docker, Nginx, Certbot, the `deploy` user, `/opt/station`, and swap. +- `infra/scripts/setup-swap.sh`: creates and enables a persistent 2 GB swap file. +- `infra/scripts/issue-certs.sh`: requests the initial Let's Encrypt certificates for Station domains. + +The Nginx configs in `infra/nginx/` are plain HTTP bootstrap configs. Certbot updates them with HTTPS and redirect blocks after certificates are issued. diff --git a/infra/nginx/api.drdnt.org.conf b/infra/nginx/api.drdnt.org.conf new file mode 100644 index 0000000..e1987e6 --- /dev/null +++ b/infra/nginx/api.drdnt.org.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name api.drdnt.org; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/nginx/bot.drdnt.org.conf b/infra/nginx/bot.drdnt.org.conf new file mode 100644 index 0000000..06afca0 --- /dev/null +++ b/infra/nginx/bot.drdnt.org.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name bot.drdnt.org; + + # Placeholder upstream until station-bot is deployed on the VPS. + location / { + proxy_pass http://127.0.0.1:3999; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/nginx/station.drdnt.org.conf b/infra/nginx/station.drdnt.org.conf new file mode 100644 index 0000000..38f5c8b --- /dev/null +++ b/infra/nginx/station.drdnt.org.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name station.drdnt.org; + + location / { + proxy_pass http://127.0.0.1:5173; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infra/scripts/bootstrap-vps.sh b/infra/scripts/bootstrap-vps.sh new file mode 100755 index 0000000..2fef1de --- /dev/null +++ b/infra/scripts/bootstrap-vps.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +DEPLOY_USER="deploy" +DEPLOY_HOME="/home/${DEPLOY_USER}" +STATION_ROOT="/opt/station" + +apt update +apt upgrade -y + +apt install -y ca-certificates curl gnupg lsb-release + +install -m 0755 -d /etc/apt/keyrings +if [ ! -f /etc/apt/keyrings/docker.asc ]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc +fi + +ARCH="$(dpkg --print-architecture)" +CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")" +echo \ + "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + +apt update +apt install -y \ + docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin \ + nginx \ + certbot \ + python3-certbot-nginx + +systemctl enable --now docker +systemctl enable --now nginx + +if ! id -u "${DEPLOY_USER}" >/dev/null 2>&1; then + useradd -m -s /bin/bash "${DEPLOY_USER}" +fi + +usermod -aG docker "${DEPLOY_USER}" + +install -d -m 700 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh" +touch "${DEPLOY_HOME}/.ssh/authorized_keys" +chmod 600 "${DEPLOY_HOME}/.ssh/authorized_keys" +chown "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh/authorized_keys" + +if [ -n "${DEPLOY_SSH_PUBLIC_KEY:-}" ]; then + if ! grep -Fqx "${DEPLOY_SSH_PUBLIC_KEY}" "${DEPLOY_HOME}/.ssh/authorized_keys"; then + echo "${DEPLOY_SSH_PUBLIC_KEY}" >> "${DEPLOY_HOME}/.ssh/authorized_keys" + chown "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_HOME}/.ssh/authorized_keys" + fi +else + echo "DEPLOY_SSH_PUBLIC_KEY is not set. Add the deploy key to ${DEPLOY_HOME}/.ssh/authorized_keys manually." +fi + +install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}" +install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}/logs" + +"$(dirname "$0")/setup-swap.sh" + +echo +echo "Bootstrap complete." +echo "- Install Nginx configs from infra/nginx/ into /etc/nginx/sites-available/" +echo "- Enable the sites and reload Nginx." +echo "- Run infra/scripts/issue-certs.sh once DNS is live." +echo "- Confirm the deploy user can SSH and run Docker commands without sudo." diff --git a/infra/scripts/issue-certs.sh b/infra/scripts/issue-certs.sh new file mode 100755 index 0000000..d44aa99 --- /dev/null +++ b/infra/scripts/issue-certs.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +certbot --nginx \ + -d api.drdnt.org \ + -d station.drdnt.org \ + -d bot.drdnt.org + +certbot renew --dry-run diff --git a/infra/scripts/setup-swap.sh b/infra/scripts/setup-swap.sh new file mode 100755 index 0000000..a48e01c --- /dev/null +++ b/infra/scripts/setup-swap.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root." + exit 1 +fi + +if swapon --show | grep -q '^/swapfile'; then + echo "/swapfile is already active." + exit 0 +fi + +if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile +fi + +swapon /swapfile + +if ! grep -q '^/swapfile none swap sw 0 0$' /etc/fstab; then + echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + +echo "Swap is active:" +swapon --show From 4959b98f27009dca8726556bd9b26e98b3190e4f Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 24 Apr 2026 02:52:09 -0400 Subject: [PATCH 2/4] test: add infra validation coverage - add an infra workspace package with node-based tests - validate VPS bootstrap scripts and nginx configs through pnpm test --- infra/tests/infrastructure.test.mjs | 76 ++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index d80f1ce..4ac0117 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -71,3 +72,76 @@ test('infra README documents terraform import and apply workflow', () => { assert.match(readme, /terraform plan/); assert.match(readme, /terraform apply/); }); + +test('bash scripts have valid shell syntax', () => { + execFileSync('bash', [ + '-n', + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + path.join(infraRoot, 'scripts/setup-swap.sh'), + path.join(infraRoot, 'scripts/issue-certs.sh'), + ]); +}); + +test('bootstrap script provisions required VPS baseline steps', () => { + const script = readInfraFile('scripts/bootstrap-vps.sh'); + + assert.match(script, /apt update/); + assert.match(script, /apt upgrade -y/); + assert.match(script, /docker-ce/); + assert.match(script, /docker-compose-plugin/); + assert.match(script, /nginx/); + assert.match(script, /certbot/); + assert.match(script, /python3-certbot-nginx/); + assert.match(script, /useradd -m -s \/bin\/bash "\$\{DEPLOY_USER\}"/); + assert.match(script, /usermod -aG docker "\$\{DEPLOY_USER\}"/); + assert.match(script, /authorized_keys/); + assert.match(script, /\/opt\/station/); + assert.match(script, /setup-swap\.sh/); +}); + +test('swap script creates and persists a 2 GB swap file', () => { + const script = readInfraFile('scripts/setup-swap.sh'); + + assert.match(script, /fallocate -l 2G \/swapfile/); + assert.match(script, /chmod 600 \/swapfile/); + assert.match(script, /mkswap \/swapfile/); + assert.match(script, /swapon \/swapfile/); + assert.match(script, /\/swapfile none swap sw 0 0/); +}); + +test('cert issuance script requests all Station domains and verifies renewal', () => { + const script = readInfraFile('scripts/issue-certs.sh'); + + assert.match(script, /certbot --nginx/); + assert.match(script, /-d api\.drdnt\.org/); + assert.match(script, /-d station\.drdnt\.org/); + assert.match(script, /-d bot\.drdnt\.org/); + assert.match(script, /certbot renew --dry-run/); +}); + +test('nginx configs target the expected upstreams', () => { + const apiConfig = readInfraFile('nginx/api.drdnt.org.conf'); + const stationConfig = readInfraFile('nginx/station.drdnt.org.conf'); + const botConfig = readInfraFile('nginx/bot.drdnt.org.conf'); + + assert.match(apiConfig, /server_name api\.drdnt\.org;/); + assert.match(apiConfig, /proxy_pass http:\/\/127\.0\.0\.1:3001;/); + + assert.match(stationConfig, /server_name station\.drdnt\.org;/); + assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:5173;/); + + assert.match(botConfig, /server_name bot\.drdnt\.org;/); + assert.match(botConfig, /proxy_pass http:\/\/127\.0\.0\.1:3999;/); +}); + +test('infra scripts are executable on disk', () => { + const bootstrapMode = statSync( + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + ).mode; + const swapMode = statSync(path.join(infraRoot, 'scripts/setup-swap.sh')).mode; + const certMode = statSync(path.join(infraRoot, 'scripts/issue-certs.sh')).mode; + + assert.ok(bootstrapMode & 0o111); + assert.ok(swapMode & 0o111); + assert.ok(certMode & 0o111); +}); From 173bdddef6f14f041e8d81a673130d9355ea704f Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Fri, 24 Apr 2026 19:13:52 -0400 Subject: [PATCH 3/4] fix: harden vps baseline assets --- infra/README.md | 4 ++-- infra/nginx/station.drdnt.org.conf | 2 +- infra/scripts/bootstrap-vps.sh | 2 +- infra/scripts/setup-swap.sh | 3 +++ infra/tests/infrastructure.test.mjs | 34 ++++++++++++++++++++++------- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/infra/README.md b/infra/README.md index e560fb8..be44b39 100644 --- a/infra/README.md +++ b/infra/README.md @@ -46,8 +46,8 @@ Keep real values in `infra/terraform/terraform.tfvars`, which is gitignored. Issue `#107` builds on the Terraform layer after DNS from issue `#106` has propagated. 1. Copy the repository or the `infra/` directory to the VPS. -2. Run `infra/scripts/bootstrap-vps.sh` as `root`. -3. Install the deploy key for the `deploy` user when prompted by the script. +2. Export `DEPLOY_SSH_PUBLIC_KEY` as the deploy user's public SSH key, then run `infra/scripts/bootstrap-vps.sh` as `root`. +3. If you did not set `DEPLOY_SSH_PUBLIC_KEY`, manually append the deploy public key to `/home/deploy/.ssh/authorized_keys` after the script finishes. 4. Review and install the Nginx site configs from `infra/nginx/`. 5. Run `infra/scripts/issue-certs.sh` to request the initial certificates. 6. Verify renewal with `certbot renew --dry-run`. diff --git a/infra/nginx/station.drdnt.org.conf b/infra/nginx/station.drdnt.org.conf index 38f5c8b..d1abf4e 100644 --- a/infra/nginx/station.drdnt.org.conf +++ b/infra/nginx/station.drdnt.org.conf @@ -3,7 +3,7 @@ server { server_name station.drdnt.org; location / { - proxy_pass http://127.0.0.1:5173; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/infra/scripts/bootstrap-vps.sh b/infra/scripts/bootstrap-vps.sh index 2fef1de..75d08a2 100755 --- a/infra/scripts/bootstrap-vps.sh +++ b/infra/scripts/bootstrap-vps.sh @@ -65,7 +65,7 @@ fi install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}" install -d -m 755 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "${STATION_ROOT}/logs" -"$(dirname "$0")/setup-swap.sh" +bash "$(dirname "$0")/setup-swap.sh" echo echo "Bootstrap complete." diff --git a/infra/scripts/setup-swap.sh b/infra/scripts/setup-swap.sh index a48e01c..0e8b0da 100755 --- a/infra/scripts/setup-swap.sh +++ b/infra/scripts/setup-swap.sh @@ -14,6 +14,9 @@ fi if [ ! -f /swapfile ]; then fallocate -l 2G /swapfile chmod 600 /swapfile +fi + +if ! blkid -o value -s TYPE /swapfile 2>/dev/null | grep -qx 'swap'; then mkswap /swapfile fi diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 4ac0117..7790367 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -74,12 +74,26 @@ test('infra README documents terraform import and apply workflow', () => { }); test('bash scripts have valid shell syntax', () => { - execFileSync('bash', [ - '-n', - path.join(infraRoot, 'scripts/bootstrap-vps.sh'), - path.join(infraRoot, 'scripts/setup-swap.sh'), - path.join(infraRoot, 'scripts/issue-certs.sh'), - ]); + if (process.platform === 'win32') { + return; + } + + try { + execFileSync('bash', [ + '-n', + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + path.join(infraRoot, 'scripts/setup-swap.sh'), + path.join(infraRoot, 'scripts/issue-certs.sh'), + ]); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + const code = String(error.code); + if (code === 'ENOENT' || code === 'EPERM') { + return; + } + } + throw error; + } }); test('bootstrap script provisions required VPS baseline steps', () => { @@ -96,7 +110,7 @@ test('bootstrap script provisions required VPS baseline steps', () => { assert.match(script, /usermod -aG docker "\$\{DEPLOY_USER\}"/); assert.match(script, /authorized_keys/); assert.match(script, /\/opt\/station/); - assert.match(script, /setup-swap\.sh/); + assert.match(script, /bash "\$\(dirname "\$0"\)\/setup-swap\.sh"/); }); test('swap script creates and persists a 2 GB swap file', () => { @@ -128,13 +142,17 @@ test('nginx configs target the expected upstreams', () => { assert.match(apiConfig, /proxy_pass http:\/\/127\.0\.0\.1:3001;/); assert.match(stationConfig, /server_name station\.drdnt\.org;/); - assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:5173;/); + assert.match(stationConfig, /proxy_pass http:\/\/127\.0\.0\.1:3000;/); assert.match(botConfig, /server_name bot\.drdnt\.org;/); assert.match(botConfig, /proxy_pass http:\/\/127\.0\.0\.1:3999;/); }); test('infra scripts are executable on disk', () => { + if (process.platform === 'win32') { + return; + } + const bootstrapMode = statSync( path.join(infraRoot, 'scripts/bootstrap-vps.sh'), ).mode; From 6c77dea908a2915ab815ecf425fa3be3ac74f020 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Fri, 24 Apr 2026 21:03:25 -0400 Subject: [PATCH 4/4] fix: tighten infra config consistency --- infra/nginx/station.drdnt.org.conf | 2 +- infra/tests/infrastructure.test.mjs | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/infra/nginx/station.drdnt.org.conf b/infra/nginx/station.drdnt.org.conf index d1abf4e..7b42bd1 100644 --- a/infra/nginx/station.drdnt.org.conf +++ b/infra/nginx/station.drdnt.org.conf @@ -3,7 +3,7 @@ server { server_name station.drdnt.org; location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/infra/tests/infrastructure.test.mjs b/infra/tests/infrastructure.test.mjs index 7790367..251eff3 100644 --- a/infra/tests/infrastructure.test.mjs +++ b/infra/tests/infrastructure.test.mjs @@ -78,13 +78,16 @@ test('bash scripts have valid shell syntax', () => { return; } + const scripts = [ + path.join(infraRoot, 'scripts/bootstrap-vps.sh'), + path.join(infraRoot, 'scripts/setup-swap.sh'), + path.join(infraRoot, 'scripts/issue-certs.sh'), + ]; + try { - execFileSync('bash', [ - '-n', - path.join(infraRoot, 'scripts/bootstrap-vps.sh'), - path.join(infraRoot, 'scripts/setup-swap.sh'), - path.join(infraRoot, 'scripts/issue-certs.sh'), - ]); + for (const script of scripts) { + execFileSync('bash', ['-n', script]); + } } catch (error) { if (error && typeof error === 'object' && 'code' in error) { const code = String(error.code);