diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8025e5 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +Info: +-- +Server: + Strato STRATO VPS Linux VC4-8 + Order Number: 9478462 + + Customer Number: 73171334 + Customer Login: https://www.strato.de/apps/CustomerService + + Cores: 4 Cores + RAM: 8 GB RAM + Storage: 240 HDD + + IPv4: 217.154.207.148 + IPv6: 2a01:239:35b:c400::1 + + OS: Ubuntu 24.04 LTS + +DNS Names: + cloud.ladkau.de + gitea.ladkau.de + nextcloud.ladkau.de + sso.ladkau.de + mail.ladkau.de + cr.ladkau.de + k8s.ladkau.de + +Purpose: +-- +This repository tracks the complete server configuration so the server can be +fully reinstalled from scratch using only this repo. Every configuration change +must be committed here. The git history serves as the change log. + +Architecture: +-- +Provisioning: Ansible + Ansible playbooks configure the OS and deploy all services. Roles are + idempotent — re-running them brings the server back to the desired state + without side effects. The master playbook is ansible/site.yml. + +Service runtime: Docker Compose + Each service runs as a Docker Compose stack. Compose files live inside + their respective Ansible roles (roles//files/docker-compose.yml). + This keeps service definition and deployment config together. + +Reverse proxy / TLS: Traefik + Traefik is the single entry point for all HTTP/HTTPS traffic. It runs as + a Docker Compose service and routes to other containers via Docker labels. + TLS certificates are issued automatically via Let's Encrypt (ACME). + +Repo layout: +-- + ansible/ + inventory.ini # host address and connection vars + site.yml # master playbook — runs all roles in order + group_vars/all.yml # shared variables (domains, image versions, ...) + roles/ + base/ # OS hardening, non-root user, SSH, ufw firewall + docker/ # Docker Engine + Compose plugin install + traefik/ # reverse proxy, TLS termination + gitea/ # self-hosted Git + nextcloud/ # file storage and collaboration + sso/ # Single Sign-On + mail/ # mail server + registry/ # container registry (cr.ladkau.de) + k8s/ # k3s Kubernetes node + docs/ + runbook.md # step-by-step reinstall instructions + keys/ + *.pub # public SSH keys (private keys are gitignored) + +SSH keys: +-- + root_cloud_ladkau_de — root access (initial setup only) + notroot_cloud_ladkau_de — non-root deploy user (used by Ansible) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000..d968bbe --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,22 @@ +--- +# Domains +domain_base: ladkau.de +domain_cloud: "cloud.{{ domain_base }}" +domain_gitea: "gitea.{{ domain_base }}" +domain_nextcloud: "nextcloud.{{ domain_base }}" +domain_sso: "sso.{{ domain_base }}" +domain_mail: "mail.{{ domain_base }}" +domain_registry: "cr.{{ domain_base }}" +domain_k8s: "k8s.{{ domain_base }}" + +# Let's Encrypt +acme_email: matthias.ladkau@gmail.com + +# Non-root deploy user created by the base role +deploy_user: deploy + +# System timezone +timezone: Europe/Berlin + +# Docker network shared by all services and Traefik +traefik_network: traefik_public diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 0000000..d4e10f8 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,2 @@ +[cloud] +cloud.ladkau.de ansible_user=deploy ansible_ssh_private_key_file=../keys/notroot_cloud_ladkau_de diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..11e9338 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.general # ufw, timezone modules + - name: ansible.posix # authorized_key module diff --git a/ansible/roles/base/files/20auto-upgrades b/ansible/roles/base/files/20auto-upgrades new file mode 100644 index 0000000..8d6d7c8 --- /dev/null +++ b/ansible/roles/base/files/20auto-upgrades @@ -0,0 +1,2 @@ +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; diff --git a/ansible/roles/base/handlers/main.yml b/ansible/roles/base/handlers/main.yml new file mode 100644 index 0000000..cad74d8 --- /dev/null +++ b/ansible/roles/base/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart sshd + ansible.builtin.service: + name: ssh + state: restarted diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..0c7aeaa --- /dev/null +++ b/ansible/roles/base/tasks/main.yml @@ -0,0 +1,134 @@ +--- +# --- Packages --- + +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + tags: base + +- name: Upgrade all packages + ansible.builtin.apt: + upgrade: dist + tags: base + +- name: Install base packages + ansible.builtin.apt: + name: + - curl + - git + - vim + - htop + - ufw + - fail2ban + - unattended-upgrades + - apt-listchanges + state: present + tags: base + +# --- System --- + +- name: Set timezone + community.general.timezone: + name: "{{ timezone }}" + tags: base + +# --- Deploy user --- + +- name: Create deploy user + ansible.builtin.user: + name: "{{ deploy_user }}" + shell: /bin/bash + create_home: true + groups: sudo + append: true + state: present + tags: base + +- name: Add SSH authorized key for deploy user + ansible.posix.authorized_key: + user: "{{ deploy_user }}" + state: present + key: "{{ lookup('file', playbook_dir + '/../keys/notroot_cloud_ladkau_de.pub') }}" + tags: base + +- name: Allow deploy user passwordless sudo + ansible.builtin.copy: + dest: /etc/sudoers.d/{{ deploy_user }} + content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n" + mode: "0440" + validate: visudo -cf %s + tags: base + +# --- SSH hardening --- + +- name: Deploy hardened sshd_config + ansible.builtin.template: + src: sshd_config.j2 + dest: /etc/ssh/sshd_config + owner: root + group: root + mode: "0600" + validate: sshd -t -f %s + notify: Restart sshd + tags: base + +# --- Firewall --- + +- name: Set UFW default incoming policy to deny + community.general.ufw: + direction: incoming + policy: deny + tags: base + +- name: Set UFW default outgoing policy to allow + community.general.ufw: + direction: outgoing + policy: allow + tags: base + +- name: Allow SSH (22/tcp) + community.general.ufw: + rule: allow + port: "22" + proto: tcp + tags: base + +- name: Allow HTTP (80/tcp) + community.general.ufw: + rule: allow + port: "80" + proto: tcp + tags: base + +- name: Allow HTTPS (443/tcp) + community.general.ufw: + rule: allow + port: "443" + proto: tcp + tags: base + +- name: Enable UFW + community.general.ufw: + state: enabled + tags: base + +# --- Automatic security updates --- + +- name: Enable unattended-upgrades + ansible.builtin.copy: + src: 20auto-upgrades + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + tags: base + +# --- Fail2ban --- + +- name: Enable and start fail2ban + ansible.builtin.service: + name: fail2ban + state: started + enabled: true + tags: base diff --git a/ansible/roles/base/templates/sshd_config.j2 b/ansible/roles/base/templates/sshd_config.j2 new file mode 100644 index 0000000..12bb970 --- /dev/null +++ b/ansible/roles/base/templates/sshd_config.j2 @@ -0,0 +1,23 @@ +# Managed by Ansible — do not edit manually +Port 22 +Protocol 2 + +# Authentication +PermitRootLogin no +PasswordAuthentication no +ChallengeResponseAuthentication no +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys + +# Only allow the deploy user over SSH +AllowUsers {{ deploy_user }} + +# Misc hardening +X11Forwarding no +PrintMotd no +MaxAuthTries 3 +LoginGraceTime 30 + +UsePAM yes +AcceptEnv LANG LC_* +Subsystem sftp /usr/lib/openssh/sftp-server diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..a9f7360 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement docker role diff --git a/ansible/roles/gitea/tasks/main.yml b/ansible/roles/gitea/tasks/main.yml new file mode 100644 index 0000000..44c3e55 --- /dev/null +++ b/ansible/roles/gitea/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement gitea role diff --git a/ansible/roles/k8s/tasks/main.yml b/ansible/roles/k8s/tasks/main.yml new file mode 100644 index 0000000..d61459d --- /dev/null +++ b/ansible/roles/k8s/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement k8s role diff --git a/ansible/roles/mail/tasks/main.yml b/ansible/roles/mail/tasks/main.yml new file mode 100644 index 0000000..f8a70ba --- /dev/null +++ b/ansible/roles/mail/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement mail role diff --git a/ansible/roles/nextcloud/tasks/main.yml b/ansible/roles/nextcloud/tasks/main.yml new file mode 100644 index 0000000..3edefc3 --- /dev/null +++ b/ansible/roles/nextcloud/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement nextcloud role diff --git a/ansible/roles/registry/tasks/main.yml b/ansible/roles/registry/tasks/main.yml new file mode 100644 index 0000000..efcfc32 --- /dev/null +++ b/ansible/roles/registry/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement registry role diff --git a/ansible/roles/sso/tasks/main.yml b/ansible/roles/sso/tasks/main.yml new file mode 100644 index 0000000..39a7c18 --- /dev/null +++ b/ansible/roles/sso/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement sso role diff --git a/ansible/roles/traefik/tasks/main.yml b/ansible/roles/traefik/tasks/main.yml new file mode 100644 index 0000000..0d2b6df --- /dev/null +++ b/ansible/roles/traefik/tasks/main.yml @@ -0,0 +1,2 @@ +--- +# TODO: implement traefik role diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..da97023 --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,14 @@ +--- +- name: Full server provisioning + hosts: cloud + become: true + roles: + - base + - docker + - traefik + - gitea + - nextcloud + - sso + - mail + - registry + - k8s diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..3150b76 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,88 @@ +# Reinstall Runbook + +Follow these steps to provision a fresh server from scratch. + +## Prerequisites + +On your local machine: +- Ansible installed (`pip install ansible`) +- SSH access to the server as root using `keys/root_cloud_ladkau_de` + +## Steps + +### 1. Install Ansible collections + +From the repo root: + +```bash +ansible-galaxy collection install -r ansible/requirements.yml +``` + +Required collections: +- `community.general` — ufw, timezone modules +- `ansible.posix` — authorized_key module + +### 2. Initial root login + +Connect as root and verify the server is reachable: + +``` +ssh -i keys/root_cloud_ladkau_de root@217.154.207.148 +``` + +### 3. Create the deploy user (one-time, manual) + +On the server as root: + +```bash +adduser deploy +usermod -aG sudo deploy +mkdir -p /home/deploy/.ssh +cat >> /home/deploy/.ssh/authorized_keys <<'EOF' + +EOF +chown -R deploy:deploy /home/deploy/.ssh +chmod 700 /home/deploy/.ssh +chmod 600 /home/deploy/.ssh/authorized_keys +``` + +### 4. Run the Ansible master playbook + +From the repo root: + +```bash +ansible-playbook -i ansible/inventory.ini ansible/site.yml +``` + +This runs all roles in order: +1. `base` — OS hardening, SSH config, ufw firewall +2. `docker` — Docker Engine + Compose plugin +3. `traefik` — reverse proxy, TLS via Let's Encrypt +4. `gitea` — self-hosted Git +5. `nextcloud` — file storage +6. `sso` — Single Sign-On +7. `mail` — mail server +8. `registry` — container registry +9. `k8s` — k3s node + +### 5. DNS + +Ensure the following DNS A records point to `217.154.207.148` before running: + +- cloud.ladkau.de +- gitea.ladkau.de +- nextcloud.ladkau.de +- sso.ladkau.de +- mail.ladkau.de +- cr.ladkau.de +- k8s.ladkau.de + +Traefik will attempt ACME certificate issuance on first start; DNS must resolve first. + +## Re-running after changes + +The playbook is idempotent. To apply a single role only: + +```bash +ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags +```