Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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`.

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.
13 changes: 13 additions & 0 deletions infra/nginx/api.drdnt.org.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 14 additions & 0 deletions infra/nginx/bot.drdnt.org.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions infra/nginx/station.drdnt.org.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
server {
listen 80;
server_name station.drdnt.org;

location / {
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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
75 changes: 75 additions & 0 deletions infra/scripts/bootstrap-vps.sh
Original file line number Diff line number Diff line change
@@ -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"

bash "$(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."
14 changes: 14 additions & 0 deletions infra/scripts/issue-certs.sh
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions infra/scripts/setup-swap.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/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
fi

if ! blkid -o value -s TYPE /swapfile 2>/dev/null | grep -qx 'swap'; then
mkswap /swapfile
fi

swapon /swapfile
Comment thread
GitAddRemote marked this conversation as resolved.

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
97 changes: 96 additions & 1 deletion infra/tests/infrastructure.test.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -71,3 +72,97 @@ 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', () => {
if (process.platform === 'win32') {
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 {
for (const script of scripts) {
execFileSync('bash', ['-n', script]);
}
} catch (error) {
if (error && typeof error === 'object' && 'code' in error) {
const code = String(error.code);
if (code === 'ENOENT' || code === 'EPERM') {
return;
}
}
throw error;
}
});
Comment thread
GitAddRemote marked this conversation as resolved.

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, /bash "\$\(dirname "\$0"\)\/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: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;
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);
Comment thread
GitAddRemote marked this conversation as resolved.
});
Loading