Skip to content

VPS Setup Runbook — docs.swisper.io

Overview

This runbook documents the full procedure for provisioning and configuring the EU VPS that serves the Swisper documentation site (docs.swisper.io).

Property Value
Server Hetzner Cloud CX22 (2 vCPU, 4 GB RAM, 40 GB disk)
Location Falkenstein (Germany) or Helsinki (Finland)
OS Ubuntu 22.04 LTS
Web server nginx with TLS (Let's Encrypt) + basic auth
Deploy target /var/www/docs/ via rsync over SSH
Domain docs.swisper.io
Monthly cost ~€5–7
Estimated setup time 30–45 minutes
Risk level Low (net-new infrastructure, no existing data at risk)

Spec reference: Solution Architecture Spec §5.8, artifact N5.


Prerequisites

Before starting, ensure you have:

  • [ ] A Hetzner Cloud account (https://console.hetzner.cloud)
  • [ ] DNS management access for swisper.io (to create an A record)
  • [ ] An SSH key pair on your local machine (~/.ssh/id_ed25519 or similar)
  • [ ] A second SSH key pair for the CI/CD deploy user (generate if needed — see Step 6)
  • [ ] A chosen username and password for basic auth (share via 1Password, never commit)
  • [ ] The apache2-utils package locally if you want to pre-generate .htpasswd entries

Step 1: Provision the VPS on Hetzner Cloud

  1. Log in to Hetzner Cloud Console.

  2. Click Add Server and configure:

Setting Value
Location Falkenstein (fsn1) or Helsinki (hel1)
Image Ubuntu 22.04
Type CX22 (2 vCPU, 4 GB RAM, 40 GB disk)
SSH Key Select or upload your public key
Name docs-swisper-io
  1. Click Create & Buy Now.

  2. Note the assigned IPv4 address — referred to as <VPS_IP> throughout this runbook.

  3. Verify SSH access:

ssh root@<VPS_IP>

You should get a root shell. If not, check your SSH key and Hetzner firewall settings.


Step 2: Initial Server Hardening

All commands in this step run as root on the VPS.

2.1 Update system packages

apt update && apt upgrade -y

2.2 Configure unattended security updates

apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades

Select Yes when prompted to enable automatic security updates.

2.3 Configure SSH hardening

Edit /etc/ssh/sshd_config and ensure these settings:

PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes

Restart SSH:

systemctl restart sshd

2.4 Enable firewall (UFW)

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status

Expected output should show OpenSSH and Nginx Full as ALLOW from Anywhere.


Step 3: Install and Configure nginx

3.1 Install nginx

apt install -y nginx
systemctl enable nginx

3.2 Create the document root

mkdir -p /var/www/docs
chown www-data:www-data /var/www/docs

3.3 Create a temporary index page

This placeholder confirms nginx is serving from the correct root before TLS and auth are configured:

echo '<html><body><h1>docs.swisper.io — setup in progress</h1></body></html>' \
  > /var/www/docs/index.html
chown www-data:www-data /var/www/docs/index.html

3.4 Create the nginx server block

Create /etc/nginx/sites-available/docs.swisper.io:

server {
    listen 80;
    server_name docs.swisper.io;

    root /var/www/docs;
    index index.html;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }
}

Note: This is a temporary HTTP-only config. Certbot will modify it to add TLS in Step 4. The final HTTPS config with basic auth is applied in Step 5.

3.5 Enable the site and test

ln -s /etc/nginx/sites-available/docs.swisper.io /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx

nginx -t must output syntax is ok and test is successful. If it fails, check the config file for typos.


Step 4: Configure DNS and TLS via Let's Encrypt

4.1 Create DNS A record

In your DNS provider's management console for swisper.io, create:

Type Name Value TTL
A docs <VPS_IP> 300 (5 min, lower for initial setup)

4.2 Verify DNS propagation

Wait a few minutes, then:

dig +short docs.swisper.io

This must return <VPS_IP>. If it doesn't, wait longer or check the DNS record.

You can also test from outside:

curl -s -o /dev/null -w "%{http_code}" http://docs.swisper.io

Should return 200 (the temporary index page).

4.3 Install certbot and obtain TLS certificate

apt install -y certbot python3-certbot-nginx
certbot --nginx -d docs.swisper.io

Certbot will prompt for: - An email address for renewal notices - Agreement to terms of service - Whether to redirect HTTP to HTTPS — select Yes (redirect)

Certbot automatically: - Obtains a Let's Encrypt certificate - Modifies the nginx config to serve over HTTPS - Sets up HTTP → HTTPS redirect

4.4 Verify TLS auto-renewal

certbot renew --dry-run

This must succeed. Certbot installs a systemd timer (certbot.timer) that runs renewal checks twice daily. Verify it is active:

systemctl status certbot.timer

4.5 Verify HTTPS is working

curl -s -o /dev/null -w "%{http_code}" https://docs.swisper.io

Should return 200. The temporary index page should now be served over HTTPS.


Step 5: Configure Basic Auth

5.1 Install apache2-utils (for htpasswd)

apt install -y apache2-utils

5.2 Create the .htpasswd file

htpasswd -c /etc/nginx/.htpasswd <username>

You will be prompted for a password. Replace <username> with the chosen username.

To add additional users later:

htpasswd /etc/nginx/.htpasswd <another_username>

Credential management: Share credentials via a secure channel (e.g., 1Password). Never commit credentials to the repository. If the team grows beyond ~10 people, consider upgrading to OAuth2 Proxy in front of nginx.

5.3 Apply the final nginx configuration

Replace the contents of /etc/nginx/sites-available/docs.swisper.io with the production config from the spec:

server {
    listen 443 ssl http2;
    server_name docs.swisper.io;

    ssl_certificate /etc/letsencrypt/live/docs.swisper.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/docs.swisper.io/privkey.pem;

    root /var/www/docs;
    index index.html;

    # Basic auth
    auth_basic "Swisper Documentation";
    auth_basic_user_file /etc/nginx/.htpasswd;

    # Static file serving
    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Cache static assets (CSS, JS, fonts, images)
    location ~* \.(css|js|woff2?|ttf|eot|svg|png|jpg|gif|ico)$ {
        expires 7d;
        add_header Cache-Control "public, immutable";
    }
}

server {
    listen 80;
    server_name docs.swisper.io;
    return 301 https://$host$request_uri;
}

Note: Certbot may have added its own ssl directives and include lines. The config above is the canonical target from the spec. You may need to incorporate certbot's include /etc/letsencrypt/options-ssl-nginx.conf; and ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; lines if certbot created them during Step 4. The key additions over what certbot generates are: auth_basic, auth_basic_user_file, the try_files directive, and the static asset caching block.

5.4 Test and reload nginx

nginx -t
systemctl reload nginx

5.5 Verify basic auth

Unauthenticated request must return 401:

curl -s -o /dev/null -w "%{http_code}" https://docs.swisper.io

Expected: 401

Authenticated request must return 200:

curl -s -o /dev/null -w "%{http_code}" -u <username>:<password> https://docs.swisper.io

Expected: 200


Step 6: Create the Deploy User for CI/CD

6.1 Generate a deploy SSH key pair (on your local machine)

If you haven't already, generate a dedicated key pair for CI/CD:

ssh-keygen -t ed25519 -f ~/.ssh/docs-deploy-key -C "docs-deploy@swisper.io" -N ""

This creates: - ~/.ssh/docs-deploy-key (private key — goes into GitHub Actions secret) - ~/.ssh/docs-deploy-key.pub (public key — goes onto the VPS)

6.2 Create the deploy user on the VPS

Run these commands as root on the VPS:

useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

6.3 Add the deploy public key

Copy the contents of ~/.ssh/docs-deploy-key.pub from your local machine and add it to the VPS:

echo "<paste-public-key-contents-here>" > /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

6.4 Grant write access to the document root

chown -R deploy:www-data /var/www/docs
chmod -R 775 /var/www/docs

This allows the deploy user to write files (for rsync) and www-data (nginx) to read them.

6.5 Verify deploy user SSH access

From your local machine:

ssh -i ~/.ssh/docs-deploy-key deploy@<VPS_IP> "echo 'Deploy user SSH OK'"

Expected output: Deploy user SSH OK

6.6 Verify deploy user can write to document root

ssh -i ~/.ssh/docs-deploy-key deploy@<VPS_IP> "touch /var/www/docs/deploy-test && rm /var/www/docs/deploy-test && echo 'Write access OK'"

Expected output: Write access OK


Step 7: Configure GitHub Actions Secrets

In the GitHub repository (Fintama/helvetiq or the docs repo), go to Settings → Secrets and variables → Actions and add:

Secret Name Value Description
DOCS_VPS_HOST <VPS_IP> (or docs.swisper.io once DNS is stable) Hostname/IP of the VPS
DOCS_VPS_USER deploy SSH username on the VPS
DOCS_VPS_SSH_KEY Contents of ~/.ssh/docs-deploy-key (private key) SSH private key for rsync

Security: The private key should only be stored in GitHub Secrets and in 1Password. Delete the local copy from ~/.ssh/docs-deploy-key after uploading, or store it in a secure vault.


Verification Checklist

Run these checks after completing all steps to confirm the setup matches the Definition of Done.

V1: VPS location (EU)

curl -s https://ipinfo.io/<VPS_IP> | grep -E '"country"|"city"|"region"'

Expected: country = DE (Germany) or FI (Finland).

V2: Unauthenticated request returns 401

curl -s -o /dev/null -w "%{http_code}" https://docs.swisper.io

Expected: 401

V3: Authenticated request returns 200

curl -s -o /dev/null -w "%{http_code}" -u <username>:<password> https://docs.swisper.io

Expected: 200

V4: TLS certificate is valid (Let's Encrypt)

echo | openssl s_client -servername docs.swisper.io -connect docs.swisper.io:443 2>/dev/null | openssl x509 -noout -issuer -dates

Expected output should show: - issuer= ... Let's Encrypt ... - notBefore= and notAfter= dates (certificate valid for ~90 days)

V5: Deploy user SSH access

ssh -i ~/.ssh/docs-deploy-key deploy@<VPS_IP> "echo 'SSH OK'"

Expected: SSH OK

V6: Deploy user can write to /var/www/docs/

ssh -i ~/.ssh/docs-deploy-key deploy@<VPS_IP> "touch /var/www/docs/test-write && rm /var/www/docs/test-write && echo 'Write OK'"

Expected: Write OK

V7: rsync deploy simulation

mkdir -p /tmp/docs-test && echo '<h1>test</h1>' > /tmp/docs-test/index.html
rsync -avz --dry-run /tmp/docs-test/ deploy@<VPS_IP>:/var/www/docs/
rm -rf /tmp/docs-test

The dry run should list the file to be transferred with no errors. Remove --dry-run to test an actual deploy.


Troubleshooting

nginx fails to start or reload

nginx -t
journalctl -u nginx --no-pager -n 50

Common causes: - Syntax error in config file (check the line number from nginx -t) - Port 80 or 443 already in use (ss -tlnp | grep -E ':(80|443)') - Missing SSL certificate (run certbot first, then apply the HTTPS config)

Certbot fails to obtain certificate

certbot --nginx -d docs.swisper.io --dry-run

Common causes: - DNS A record not yet propagated (dig +short docs.swisper.io) - Port 80 blocked by firewall (ufw status) - Another process using port 80 (ss -tlnp | grep :80)

Basic auth not working (always 200 or always 403)

ls -la /etc/nginx/.htpasswd
cat /etc/nginx/.htpasswd

Verify: - The .htpasswd file exists and is readable by nginx (www-data) - The file contains at least one user entry (format: username:$apr1$...) - The auth_basic_user_file path in nginx config matches the actual file path

Deploy user cannot write to /var/www/docs/

ls -la /var/www/ | grep docs
id deploy

Verify: - /var/www/docs/ is owned by deploy:www-data - Permissions are 775 (owner and group can write) - The deploy user exists and is in the correct group

SSH connection refused for deploy user

ssh -vvv -i ~/.ssh/docs-deploy-key deploy@<VPS_IP>

Check: - The public key is in /home/deploy/.ssh/authorized_keys - Permissions: .ssh/ is 700, authorized_keys is 600 - Ownership: all files owned by deploy:deploy - SSH service is running: systemctl status sshd


Rollback

If the VPS needs to be decommissioned or rebuilt:

  1. Delete the server from Hetzner Cloud Console (stops billing immediately).
  2. Remove the DNS A record for docs.swisper.io.
  3. Remove GitHub Actions secrets (DOCS_VPS_HOST, DOCS_VPS_USER, DOCS_VPS_SSH_KEY) from the repository.
  4. Temporary alternative: If the site must remain accessible, deploy to GitHub Pages as a fallback (requires making the repo public or using GitHub Enterprise).

The site content is always in Git — nothing is lost when the VPS is removed.


Contacts

Role Name
VPS owner / admin Heiko Sundermann
DNS management Heiko Sundermann
CI/CD pipeline See .github/workflows/docs.yml

Appendix: Spec-Defined nginx Configuration

The following nginx configuration is the canonical target from the Solution Architecture Spec (§5.8, artifact N5). The setup steps in this runbook produce this configuration.

server {
    listen 443 ssl http2;
    server_name docs.swisper.io;

    ssl_certificate /etc/letsencrypt/live/docs.swisper.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/docs.swisper.io/privkey.pem;

    root /var/www/docs;
    index index.html;

    # Basic auth
    auth_basic "Swisper Documentation";
    auth_basic_user_file /etc/nginx/.htpasswd;

    # Static file serving
    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Cache static assets (CSS, JS, fonts, images)
    location ~* \.(css|js|woff2?|ttf|eot|svg|png|jpg|gif|ico)$ {
        expires 7d;
        add_header Cache-Control "public, immutable";
    }
}

server {
    listen 80;
    server_name docs.swisper.io;
    return 301 https://$host$request_uri;
}