Fix provisioning errors found during first real run

- ansible.cfg: suppress Python interpreter discovery warning
- ansible/inventory.ini: add ansible_host; server IP is now defined in
  one place and read dynamically by run-bootstrap.sh
- group_vars/all/ → directory layout so vault.yml is auto-loaded by Ansible
  (previously vault.yml did not match any group name and was silently ignored)
- scripts/run-bootstrap.sh: read server IP from inventory, chmod 600 private
  keys automatically, show actual SSH error when root login fails
- scripts/bootstrap-deploy-user.sh: add sudoers.d entry for passwordless sudo
  (deploy user has no password so sudo group membership alone was not enough)
- nextcloud: fix Redis healthcheck (CMD-SHELL pipe was unreliable in Alpine,
  switched to CMD form with start_period)
- group_vars/all/vars.yml: fix Roundcube image tag (1.6-apache and 1.6 do not
  exist; correct tag is 1.6.x-apache)
- docs/runbook.md: expand prerequisites (SSH keys section), add step 1 for
  setting the server IP, expand bootstrap step with preflight detail, fix
  step numbering
This commit is contained in:
ml
2026-06-28 05:53:29 +02:00
parent 03d2f6d57a
commit a462ff1729
9 changed files with 87 additions and 37 deletions
+1 -1
View File
@@ -2,5 +2,5 @@ keys/*
!keys/*.* !keys/*.*
# Ansible Vault secrets — never commit plain-text secrets # Ansible Vault secrets — never commit plain-text secrets
ansible/group_vars/vault.yml ansible/group_vars/all/vault.yml
+2
View File
@@ -0,0 +1,2 @@
[defaults]
interpreter_python = auto_silent
@@ -26,7 +26,7 @@ traefik_version: v3.3
traefik_data_dir: /opt/traefik traefik_data_dir: /opt/traefik
# htpasswd-formatted user list for the dashboard. # htpasswd-formatted user list for the dashboard.
# Generate with: echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g' # Generate with: echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g'
# Store the actual value in ansible/group_vars/vault.yml (Ansible Vault). # Store the actual value in ansible/group_vars/all/vault.yml (Ansible Vault).
traefik_dashboard_users: "" traefik_dashboard_users: ""
# Gitea # Gitea
@@ -36,7 +36,7 @@ gitea_data_dir: /opt/gitea
# Set to false for the very first deploy so the admin account can be created, # Set to false for the very first deploy so the admin account can be created,
# then flip to true and redeploy to close public registration. # then flip to true and redeploy to close public registration.
gitea_disable_registration: true gitea_disable_registration: true
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) # Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# gitea_db_password: "" # gitea_db_password: ""
# gitea_secret_key: "" # generate: openssl rand -hex 32 # gitea_secret_key: "" # generate: openssl rand -hex 32
# gitea_internal_token: "" # generate: openssl rand -hex 32 # gitea_internal_token: "" # generate: openssl rand -hex 32
@@ -46,7 +46,7 @@ keycloak_version: "26.2"
keycloak_db_version: "16" keycloak_db_version: "16"
sso_data_dir: /opt/sso sso_data_dir: /opt/sso
keycloak_admin_user: admin keycloak_admin_user: admin
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) # Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# keycloak_db_password: "" # keycloak_db_password: ""
# keycloak_admin_password: "" # keycloak_admin_password: ""
@@ -55,12 +55,12 @@ nextcloud_version: "31-apache" # apache variant includes a working web server
nextcloud_db_version: "16" nextcloud_db_version: "16"
nextcloud_data_dir: /opt/nextcloud nextcloud_data_dir: /opt/nextcloud
nextcloud_admin_user: admin nextcloud_admin_user: admin
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) # Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# nextcloud_db_password: "" # nextcloud_db_password: ""
# nextcloud_admin_password: "" # nextcloud_admin_password: ""
# Mail (Roundcube webmail client) # Mail (Roundcube webmail client)
roundcube_version: "1.6-apache" roundcube_version: "1.6.x-apache"
roundcube_db_version: "16" roundcube_db_version: "16"
mail_data_dir: /opt/mail mail_data_dir: /opt/mail
# IMAP/SMTP — set to the mail server Roundcube should connect to. # IMAP/SMTP — set to the mail server Roundcube should connect to.
@@ -70,7 +70,7 @@ roundcube_imap_host: ""
roundcube_imap_port: "993" roundcube_imap_port: "993"
roundcube_smtp_host: "" roundcube_smtp_host: ""
roundcube_smtp_port: "587" roundcube_smtp_port: "587"
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) # Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# roundcube_db_password: "" # roundcube_db_password: ""
# roundcube_des_key: "" # generate: openssl rand -hex 12 (must be exactly 24 chars) # roundcube_des_key: "" # generate: openssl rand -hex 12 (must be exactly 24 chars)
@@ -79,7 +79,7 @@ registry_data_dir: /opt/registry
# registry_htpasswd — full htpasswd file content, store in vault.yml # registry_htpasswd — full htpasswd file content, store in vault.yml
# Generate with: docker run --entrypoint htpasswd httpd:2 -Bbn <user> <password> # Generate with: docker run --entrypoint htpasswd httpd:2 -Bbn <user> <password>
# Multiple users: run the command once per user and concatenate the lines. # Multiple users: run the command once per user and concatenate the lines.
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault) # Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
# registry_htpasswd: "" # registry_htpasswd: ""
# k8s (placeholder) # k8s (placeholder)
+1 -1
View File
@@ -1,2 +1,2 @@
[cloud] [cloud]
cloud.ladkau.de ansible_user=deploy ansible_ssh_private_key_file=../keys/notroot_cloud_ladkau_de cloud.ladkau.de ansible_host=217.154.207.148 ansible_user=deploy ansible_ssh_private_key_file=keys/notroot_cloud_ladkau_de
@@ -28,10 +28,11 @@ services:
networks: networks:
- nextcloud_internal - nextcloud_internal
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s
nextcloud: nextcloud:
image: nextcloud:{{ nextcloud_version }} image: nextcloud:{{ nextcloud_version }}
+51 -24
View File
@@ -32,12 +32,24 @@ ssh-keygen -t ed25519 -f keys/notroot_cloud_ladkau_de -C "deploy@cloud.ladkau.de
``` ```
Upload the root public key to the server via the Strato control panel (or paste Upload the root public key to the server via the Strato control panel (or paste
it during the initial OS install) so that `root@217.154.207.148` is accessible it during the initial OS install) so that root SSH access is available before
before running anything. running anything. The server IP is defined in `ansible/inventory.ini`
(`ansible_host`).
## Steps ## Steps
### 1. Install Ansible collections ### 1. Set the server IP
Open `ansible/inventory.ini` and set `ansible_host` to the server's public IP:
```ini
cloud.ladkau.de ansible_host=<server-ip> ansible_user=deploy ansible_ssh_private_key_file=keys/notroot_cloud_ladkau_de
```
This is the only place the IP needs to be set — the bootstrap script and all
Ansible roles read it from here.
### 2. Install Ansible collections
From the repo root: From the repo root:
@@ -50,28 +62,28 @@ Required collections:
- `ansible.posix` — authorized_key module - `ansible.posix` — authorized_key module
- `community.docker` — docker_network, docker_compose_v2 modules - `community.docker` — docker_network, docker_compose_v2 modules
### 2. Configure DNS ### 3. Configure DNS
Ensure the following DNS A records point to `217.154.207.148` before running Ensure the following DNS A records all point to the server IP (`ansible_host`
the playbook. Traefik requests Let's Encrypt certificates on first start and in `ansible/inventory.ini`) before running the playbook. Traefik requests
DNS must resolve at that point. Let's Encrypt certificates on first start and DNS must resolve at that point.
| Domain | Record | | Domain | Record |
|---------------------|--------| |---------------------|--------|
| cloud.ladkau.de | A → 217.154.207.148 | | cloud.ladkau.de | A → server IP |
| gitea.ladkau.de | A → 217.154.207.148 | | gitea.ladkau.de | A → server IP |
| nextcloud.ladkau.de | A → 217.154.207.148 | | nextcloud.ladkau.de | A → server IP |
| sso.ladkau.de | A → 217.154.207.148 | | sso.ladkau.de | A → server IP |
| mail.ladkau.de | A → 217.154.207.148 | | mail.ladkau.de | A → server IP |
| cr.ladkau.de | A → 217.154.207.148 | | cr.ladkau.de | A → server IP |
| k8s.ladkau.de | A → 217.154.207.148 | | k8s.ladkau.de | A → server IP |
### 3. Create the vault and populate secrets ### 4. Create the vault and populate secrets
Create `ansible/group_vars/vault.yml` (gitignored) and encrypt it with Ansible Vault: Create `ansible/group_vars/all/vault.yml` (gitignored) and encrypt it with Ansible Vault:
```bash ```bash
ansible-vault create ansible/group_vars/vault.yml ansible-vault create ansible/group_vars/all/vault.yml
``` ```
Populate all required secrets: Populate all required secrets:
@@ -114,19 +126,34 @@ bash scripts/check-vault.sh
To edit the vault later: To edit the vault later:
```bash ```bash
ansible-vault edit ansible/group_vars/vault.yml ansible-vault edit ansible/group_vars/all/vault.yml
``` ```
### 4. Bootstrap the deploy user ### 5. Bootstrap the deploy user
Copies the bootstrap script to the server, runs it as root, and verifies the
deploy user can log in. Also runs `check-vault.sh` as a preflight check.
```bash ```bash
bash scripts/run-bootstrap.sh bash scripts/run-bootstrap.sh
``` ```
### 5. Run the Ansible master playbook The script runs the following steps in order:
**Preflight checks (local)**
1. Verifies all four key files exist under `keys/` (both root and deploy key pairs)
2. Verifies `scripts/bootstrap-deploy-user.sh` exists
3. Sets `chmod 600` on the private key files (SSH refuses keys with open permissions)
4. Runs `scripts/check-vault.sh` — decrypts the vault and confirms all 11 required
secrets are present and non-empty
**Remote actions**
5. Opens a test SSH connection as `root` to confirm the root key works
6. Copies `bootstrap-deploy-user.sh` to `/root/` on the server via `scp`
7. Executes it as root — creates the `deploy` user, grants passwordless sudo,
and installs `keys/notroot_cloud_ladkau_de.pub` as the only authorized key
8. Opens a test SSH connection as `deploy` to confirm the new user can log in
If any step fails the script exits immediately with a descriptive error message.
### 6. Run the Ansible master playbook
```bash ```bash
ansible-playbook -i ansible/inventory.ini ansible/site.yml --ask-vault-pass ansible-playbook -i ansible/inventory.ini ansible/site.yml --ask-vault-pass
@@ -160,7 +187,7 @@ ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags <role> --ask-v
it to `false` in `vault.yml` so the setup wizard can create the admin account: it to `false` in `vault.yml` so the setup wizard can create the admin account:
```bash ```bash
ansible-vault edit ansible/group_vars/vault.yml ansible-vault edit ansible/group_vars/all/vault.yml
# add: gitea_disable_registration: false # add: gitea_disable_registration: false
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags gitea --ask-vault-pass ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags gitea --ask-vault-pass
``` ```
+4
View File
@@ -22,6 +22,10 @@ fi
echo "==> Adding '${DEPLOY_USER}' to sudo group" echo "==> Adding '${DEPLOY_USER}' to sudo group"
usermod -aG sudo "${DEPLOY_USER}" usermod -aG sudo "${DEPLOY_USER}"
echo "==> Granting '${DEPLOY_USER}' passwordless sudo"
echo "${DEPLOY_USER} ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/${DEPLOY_USER}"
chmod 440 "/etc/sudoers.d/${DEPLOY_USER}"
echo "==> Installing SSH authorized key" echo "==> Installing SSH authorized key"
SSH_DIR="/home/${DEPLOY_USER}/.ssh" SSH_DIR="/home/${DEPLOY_USER}/.ssh"
AUTH_KEYS="${SSH_DIR}/authorized_keys" AUTH_KEYS="${SSH_DIR}/authorized_keys"
+1 -1
View File
@@ -7,7 +7,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
VAULT_FILE="${REPO_ROOT}/ansible/group_vars/vault.yml" VAULT_FILE="${REPO_ROOT}/ansible/group_vars/all/vault.yml"
REQUIRED_KEYS=( REQUIRED_KEYS=(
traefik_dashboard_users traefik_dashboard_users
+18 -2
View File
@@ -9,7 +9,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER="217.154.207.148" SERVER=$(grep -oP '(?<=ansible_host=)\S+' "${REPO_ROOT}/ansible/inventory.ini" | head -1)
if [ -z "${SERVER}" ]; then
echo "ERROR: could not find ansible_host in ansible/inventory.ini" >&2
exit 1
fi
ROOT_KEY="${REPO_ROOT}/keys/root_cloud_ladkau_de" ROOT_KEY="${REPO_ROOT}/keys/root_cloud_ladkau_de"
DEPLOY_KEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de" DEPLOY_KEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de"
DEPLOY_PUBKEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de.pub" DEPLOY_PUBKEY="${REPO_ROOT}/keys/notroot_cloud_ladkau_de.pub"
@@ -37,6 +42,9 @@ if [ ! -f "${BOOTSTRAP_SCRIPT}" ]; then
exit 1 exit 1
fi fi
# SSH private keys must not be group/world readable
chmod 600 "${ROOT_KEY}" "${DEPLOY_KEY}"
# --- Vault check --- # --- Vault check ---
bash "${SCRIPT_DIR}/check-vault.sh" bash "${SCRIPT_DIR}/check-vault.sh"
@@ -46,10 +54,18 @@ SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"
# --- Verify root access --- # --- Verify root access ---
echo "==> Verifying root SSH access to ${SERVER}" echo "==> Verifying root SSH access to ${SERVER}"
if ! ssh ${SSH_OPTS} -i "${ROOT_KEY}" "root@${SERVER}" "echo ok" &>/dev/null; then if ! ssh ${SSH_OPTS} -i "${ROOT_KEY}" "root@${SERVER}" "echo ok" 2>/tmp/ssh_root_err; then
echo "ERROR: cannot connect as root to ${SERVER}" >&2 echo "ERROR: cannot connect as root to ${SERVER}" >&2
echo " SSH error: $(cat /tmp/ssh_root_err)" >&2
echo "" >&2
echo "Possible causes:" >&2
echo " - Root public key not uploaded to the server (Strato control panel)" >&2
echo " - Wrong key file: ${ROOT_KEY}" >&2
echo " - Server not yet reachable (still booting?)" >&2
rm -f /tmp/ssh_root_err
exit 1 exit 1
fi fi
rm -f /tmp/ssh_root_err
# --- Copy and run the bootstrap script --- # --- Copy and run the bootstrap script ---