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_ed25519or 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-utilspackage locally if you want to pre-generate.htpasswdentries
Step 1: Provision the VPS on Hetzner Cloud¶
-
Log in to Hetzner Cloud Console.
-
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 |
-
Click Create & Buy Now.
-
Note the assigned IPv4 address — referred to as
<VPS_IP>throughout this runbook. -
Verify SSH access:
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¶
2.2 Configure unattended security updates¶
Select Yes when prompted to enable automatic security updates.
2.3 Configure SSH hardening¶
Edit /etc/ssh/sshd_config and ensure these settings:
Restart SSH:
2.4 Enable firewall (UFW)¶
Expected output should show OpenSSH and Nginx Full as ALLOW from Anywhere.
Step 3: Install and Configure nginx¶
3.1 Install nginx¶
3.2 Create the document root¶
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:
This must return <VPS_IP>. If it doesn't, wait longer or check the DNS record.
You can also test from outside:
Should return 200 (the temporary index page).
4.3 Install certbot and obtain TLS certificate¶
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¶
This must succeed. Certbot installs a systemd timer (certbot.timer) that
runs renewal checks twice daily. Verify it is active:
4.5 Verify HTTPS is working¶
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)¶
5.2 Create the .htpasswd file¶
You will be prompted for a password. Replace <username> with the chosen
username.
To add additional users later:
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
ssldirectives andincludelines. The config above is the canonical target from the spec. You may need to incorporate certbot'sinclude /etc/letsencrypt/options-ssl-nginx.conf;andssl_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, thetry_filesdirective, and the static asset caching block.
5.4 Test and reload nginx¶
5.5 Verify basic auth¶
Unauthenticated request must return 401:
Expected: 401
Authenticated request must return 200:
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:
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:
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¶
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:
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-keyafter 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)¶
Expected: country = DE (Germany) or FI (Finland).
V2: Unauthenticated request returns 401¶
Expected: 401
V3: Authenticated request returns 200¶
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¶
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¶
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¶
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)¶
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/¶
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¶
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:
- Delete the server from Hetzner Cloud Console (stops billing immediately).
- Remove the DNS A record for
docs.swisper.io. - Remove GitHub Actions secrets (
DOCS_VPS_HOST,DOCS_VPS_USER,DOCS_VPS_SSH_KEY) from the repository. - 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;
}