Implemented all service roles

- docker: install Docker Engine from official apt repo, configure log rotation,
  create shared traefik_public network
- traefik: reverse proxy with automatic Let's Encrypt TLS, dashboard on
  cloud.ladkau.de behind basic auth
- gitea: self-hosted Git with PostgreSQL, Git-over-SSH on port 2222
- sso: Keycloak with PostgreSQL for OIDC/OAuth2 single sign-on
- nextcloud: file storage with PostgreSQL, Redis, cron sidecar, CalDAV/CardDAV
  well-known redirects
- mail: Roundcube webmail client with PostgreSQL
- registry: Docker Registry v2 with htpasswd auth, no upload size limit
- k8s: placeholder nginx page

All secrets documented in vault.yml; runbook updated with per-service
first-run notes.
This commit is contained in:
ml
2026-06-27 15:35:15 +02:00
parent 6115d9d48f
commit 75a0f21f74
30 changed files with 979 additions and 10 deletions
+3
View File
@@ -1,3 +1,6 @@
keys/* keys/*
!keys/*.* !keys/*.*
# Ansible Vault secrets — never commit plain-text secrets
ansible/group_vars/vault.yml
+64
View File
@@ -20,3 +20,67 @@ timezone: Europe/Berlin
# Docker network shared by all services and Traefik # Docker network shared by all services and Traefik
traefik_network: traefik_public traefik_network: traefik_public
# Traefik
traefik_version: v3.3
traefik_data_dir: /opt/traefik
# htpasswd-formatted user list for the dashboard.
# Generate with: echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g'
# Store the actual value in ansible/group_vars/vault.yml (Ansible Vault).
traefik_dashboard_users: ""
# Gitea
gitea_version: "1" # major-only tag — always pulls latest 1.x patch
gitea_db_version: "16" # PostgreSQL major version
gitea_data_dir: /opt/gitea
# 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.
gitea_disable_registration: true
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault)
# gitea_db_password: ""
# gitea_secret_key: "" # generate: openssl rand -hex 32
# gitea_internal_token: "" # generate: openssl rand -hex 32
# SSO (Keycloak)
keycloak_version: "26.2"
keycloak_db_version: "16"
sso_data_dir: /opt/sso
keycloak_admin_user: admin
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault)
# keycloak_db_password: ""
# keycloak_admin_password: ""
# Nextcloud
nextcloud_version: "31-apache" # apache variant includes a working web server
nextcloud_db_version: "16"
nextcloud_data_dir: /opt/nextcloud
nextcloud_admin_user: admin
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault)
# nextcloud_db_password: ""
# nextcloud_admin_password: ""
# Mail (Roundcube webmail client)
roundcube_version: "1.6-apache"
roundcube_db_version: "16"
mail_data_dir: /opt/mail
# IMAP/SMTP — set to the mail server Roundcube should connect to.
# Leave imap_host empty to let users enter their own server at login.
# Use "ssl://hostname" for implicit TLS (port 993), or plain hostname for STARTTLS (port 143).
roundcube_imap_host: ""
roundcube_imap_port: "993"
roundcube_smtp_host: ""
roundcube_smtp_port: "587"
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault)
# roundcube_db_password: ""
# roundcube_des_key: "" # generate: openssl rand -hex 12 (must be exactly 24 chars)
# Container registry (Docker Registry v2)
registry_data_dir: /opt/registry
# registry_htpasswd — full htpasswd file content, store in vault.yml
# Generate with: docker run --entrypoint htpasswd httpd:2 -Bbn <user> <password>
# Multiple users: run the command once per user and concatenate the lines.
# Secrets — store values in ansible/group_vars/vault.yml (Ansible Vault)
# registry_htpasswd: ""
# k8s (placeholder)
k8s_data_dir: /opt/k8s
+1
View File
@@ -2,3 +2,4 @@
collections: collections:
- name: community.general # ufw, timezone modules - name: community.general # ufw, timezone modules
- name: ansible.posix # authorized_key module - name: ansible.posix # authorized_key module
- name: community.docker # docker_network module
+5
View File
@@ -0,0 +1,5 @@
---
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted
+92 -1
View File
@@ -1,2 +1,93 @@
--- ---
# TODO: implement docker role # Install Docker Engine from Docker's official apt repository.
# The Ubuntu-provided docker.io package is not used — it lags behind upstream.
# --- Prerequisites ---
- name: Install apt transport packages
ansible.builtin.apt:
name:
- ca-certificates
- gnupg
state: present
update_cache: true
tags: docker
- name: Create /etc/apt/keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
tags: docker
- name: Download Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
force: false
tags: docker
- name: Add Docker apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/ubuntu
{{ ansible_distribution_release }} stable
filename: docker
state: present
tags: docker
# --- Install ---
- name: Install Docker Engine and Compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
notify: Restart docker
tags: docker
# --- Daemon configuration ---
- name: Deploy Docker daemon config
ansible.builtin.template:
src: daemon.json.j2
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
notify: Restart docker
tags: docker
# --- Service ---
- name: Enable and start Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
tags: docker
# --- Deploy user access ---
- name: Add deploy user to docker group
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: docker
append: true
tags: docker
# --- Shared Traefik network ---
- name: Create shared Traefik Docker network
community.docker.docker_network:
name: "{{ traefik_network }}"
driver: bridge
state: present
tags: docker
@@ -0,0 +1,7 @@
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart gitea
community.docker.docker_compose_v2:
project_src: "{{ gitea_data_dir }}"
state: present
pull: missing
recreate: always
+37 -1
View File
@@ -1,2 +1,38 @@
--- ---
# TODO: implement gitea role - name: Create Gitea data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ gitea_data_dir }}"
- "{{ gitea_data_dir }}/data"
- "{{ gitea_data_dir }}/db"
tags: gitea
# Git-over-SSH runs on 2222 to avoid conflict with the host SSH on 22
- name: Allow Gitea SSH (2222/tcp)
community.general.ufw:
rule: allow
port: "2222"
proto: tcp
tags: gitea
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ gitea_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart gitea
tags: gitea
- name: Start Gitea
community.docker.docker_compose_v2:
project_src: "{{ gitea_data_dir }}"
state: present
pull: missing
tags: gitea
@@ -0,0 +1,64 @@
# Managed by Ansible — do not edit manually
services:
gitea:
image: gitea/gitea:{{ gitea_version }}
container_name: gitea
restart: unless-stopped
environment:
# Database
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD={{ gitea_db_password }}
# Server
- GITEA__server__DOMAIN={{ domain_gitea }}
- GITEA__server__ROOT_URL=https://{{ domain_gitea }}
- GITEA__server__HTTP_PORT=3000
- GITEA__server__SSH_DOMAIN={{ domain_gitea }}
- GITEA__server__SSH_PORT=2222
# Security
- GITEA__security__SECRET_KEY={{ gitea_secret_key }}
- GITEA__security__INTERNAL_TOKEN={{ gitea_internal_token }}
# Disable public registration — set to false only for initial admin setup
- GITEA__service__DISABLE_REGISTRATION={{ gitea_disable_registration | lower }}
volumes:
- {{ gitea_data_dir }}/data:/data
ports:
- "2222:22"
networks:
- traefik_public
- gitea_internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`{{ domain_gitea }}`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
depends_on:
gitea-db:
condition: service_healthy
gitea-db:
image: postgres:{{ gitea_db_version }}
container_name: gitea-db
restart: unless-stopped
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD={{ gitea_db_password }}
- POSTGRES_DB=gitea
volumes:
- {{ gitea_data_dir }}/db:/var/lib/postgresql/data
networks:
- gitea_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 10s
timeout: 5s
retries: 5
networks:
traefik_public:
external: true
gitea_internal:
internal: true
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>k8s.ladkau.de</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f1117;
color: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.box {
text-align: center;
padding: 3rem;
}
h1 {
font-size: 2rem;
font-weight: 600;
color: #f8fafc;
}
</style>
</head>
<body>
<div class="box">
<h1>k8s.ladkau.de</h1>
</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart k8s
community.docker.docker_compose_v2:
project_src: "{{ k8s_data_dir }}"
state: present
pull: missing
recreate: always
+38 -1
View File
@@ -1,2 +1,39 @@
--- ---
# TODO: implement k8s role # Placeholder — serves a static HTML page at k8s.ladkau.de
- name: Create k8s placeholder directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ k8s_data_dir }}"
- "{{ k8s_data_dir }}/html"
tags: k8s
- name: Deploy placeholder HTML page
ansible.builtin.copy:
src: index.html
dest: "{{ k8s_data_dir }}/html/index.html"
owner: root
group: root
mode: "0644"
tags: k8s
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ k8s_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart k8s
tags: k8s
- name: Start k8s placeholder
community.docker.docker_compose_v2:
project_src: "{{ k8s_data_dir }}"
state: present
pull: missing
tags: k8s
@@ -0,0 +1,20 @@
# Managed by Ansible — do not edit manually
services:
k8s-placeholder:
image: nginx:alpine
container_name: k8s-placeholder
restart: unless-stopped
volumes:
- {{ k8s_data_dir }}/html:/usr/share/nginx/html:ro
networks:
- traefik_public
labels:
- "traefik.enable=true"
- "traefik.http.routers.k8s.rule=Host(`{{ domain_k8s }}`)"
- "traefik.http.routers.k8s.entrypoints=websecure"
- "traefik.http.routers.k8s.tls.certresolver=letsencrypt"
- "traefik.http.services.k8s.loadbalancer.server.port=80"
networks:
traefik_public:
external: true
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart mail
community.docker.docker_compose_v2:
project_src: "{{ mail_data_dir }}"
state: present
pull: missing
recreate: always
+29 -1
View File
@@ -1,2 +1,30 @@
--- ---
# TODO: implement mail role # Roundcube webmail client — connects to any external IMAP/SMTP server
- name: Create Roundcube data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ mail_data_dir }}"
- "{{ mail_data_dir }}/db"
tags: mail
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ mail_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart mail
tags: mail
- name: Start Roundcube
community.docker.docker_compose_v2:
project_src: "{{ mail_data_dir }}"
state: present
pull: missing
tags: mail
@@ -0,0 +1,60 @@
# Managed by Ansible — do not edit manually
services:
mail-db:
image: postgres:{{ roundcube_db_version }}
container_name: mail-db
restart: unless-stopped
environment:
POSTGRES_USER: roundcube
POSTGRES_PASSWORD: "{{ roundcube_db_password }}"
POSTGRES_DB: roundcube
volumes:
- {{ mail_data_dir }}/db:/var/lib/postgresql/data
networks:
- mail_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roundcube"]
interval: 10s
timeout: 5s
retries: 5
roundcube:
image: roundcube/roundcubemail:{{ roundcube_version }}
container_name: roundcube
restart: unless-stopped
environment:
# Database
ROUNDCUBEMAIL_DB_TYPE: pgsql
ROUNDCUBEMAIL_DB_HOST: mail-db
ROUNDCUBEMAIL_DB_PORT: "5432"
ROUNDCUBEMAIL_DB_USER: roundcube
ROUNDCUBEMAIL_DB_PASSWORD: "{{ roundcube_db_password }}"
ROUNDCUBEMAIL_DB_NAME: roundcube
# IMAP — leave empty to let users enter their own server at login,
# or set to a specific host to lock it down (e.g. ssl://imap.example.com)
ROUNDCUBEMAIL_DEFAULT_HOST: "{{ roundcube_imap_host }}"
ROUNDCUBEMAIL_DEFAULT_PORT: "{{ roundcube_imap_port }}"
# SMTP
ROUNDCUBEMAIL_SMTP_SERVER: "{{ roundcube_smtp_host }}"
ROUNDCUBEMAIL_SMTP_PORT: "{{ roundcube_smtp_port }}"
# Security — 24-character random string used to encrypt session data
ROUNDCUBEMAIL_DES_KEY: "{{ roundcube_des_key }}"
ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE: 25M
networks:
- traefik_public
- mail_internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.mail.rule=Host(`{{ domain_mail }}`)"
- "traefik.http.routers.mail.entrypoints=websecure"
- "traefik.http.routers.mail.tls.certresolver=letsencrypt"
- "traefik.http.services.mail.loadbalancer.server.port=80"
depends_on:
mail-db:
condition: service_healthy
networks:
traefik_public:
external: true
mail_internal:
internal: true
@@ -0,0 +1,7 @@
---
- name: Restart nextcloud
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_data_dir }}"
state: present
pull: missing
recreate: always
+30 -1
View File
@@ -1,2 +1,31 @@
--- ---
# TODO: implement nextcloud role - name: Create Nextcloud data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ nextcloud_data_dir }}"
- "{{ nextcloud_data_dir }}/html"
- "{{ nextcloud_data_dir }}/db"
- "{{ nextcloud_data_dir }}/redis"
tags: nextcloud
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ nextcloud_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart nextcloud
tags: nextcloud
- name: Start Nextcloud
community.docker.docker_compose_v2:
project_src: "{{ nextcloud_data_dir }}"
state: present
pull: missing
tags: nextcloud
@@ -0,0 +1,102 @@
# Managed by Ansible — do not edit manually
services:
nextcloud-db:
image: postgres:{{ nextcloud_db_version }}
container_name: nextcloud-db
restart: unless-stopped
environment:
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: "{{ nextcloud_db_password }}"
POSTGRES_DB: nextcloud
volumes:
- {{ nextcloud_data_dir }}/db:/var/lib/postgresql/data
networks:
- nextcloud_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud"]
interval: 10s
timeout: 5s
retries: 5
nextcloud-redis:
image: redis:7-alpine
container_name: nextcloud-redis
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- {{ nextcloud_data_dir }}/redis:/data
networks:
- nextcloud_internal
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 10s
timeout: 5s
retries: 5
nextcloud:
image: nextcloud:{{ nextcloud_version }}
container_name: nextcloud
restart: unless-stopped
environment:
# Database
POSTGRES_HOST: nextcloud-db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: "{{ nextcloud_db_password }}"
# Redis — used for file locking and memcache
REDIS_HOST: nextcloud-redis
# Admin bootstrap (only used on first start)
NEXTCLOUD_ADMIN_USER: "{{ nextcloud_admin_user }}"
NEXTCLOUD_ADMIN_PASSWORD: "{{ nextcloud_admin_password }}"
# URLs — must match external domain; tells Nextcloud it's behind an HTTPS proxy
NEXTCLOUD_TRUSTED_DOMAINS: "{{ domain_nextcloud }}"
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: "https://{{ domain_nextcloud }}"
OVERWRITEHOST: "{{ domain_nextcloud }}"
# PHP limits for large file uploads
PHP_MEMORY_LIMIT: 512M
PHP_UPLOAD_LIMIT: 512M
volumes:
- {{ nextcloud_data_dir }}/html:/var/www/html
networks:
- traefik_public
- nextcloud_internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`{{ domain_nextcloud }}`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
- "traefik.http.routers.nextcloud.middlewares=nextcloud-wellknown,nextcloud-headers"
# Redirect .well-known CalDAV/CardDAV to the proper Nextcloud endpoint
- "traefik.http.middlewares.nextcloud-wellknown.redirectregex.regex=^https://([^/]*)/.well-known/(card|cal)dav"
- "traefik.http.middlewares.nextcloud-wellknown.redirectregex.replacement=https://$$1/remote.php/dav/"
- "traefik.http.middlewares.nextcloud-wellknown.redirectregex.permanent=true"
# Security headers recommended by Nextcloud
- "traefik.http.middlewares.nextcloud-headers.headers.stsSeconds=15552000"
- "traefik.http.middlewares.nextcloud-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.nextcloud-headers.headers.stsPreload=true"
depends_on:
nextcloud-db:
condition: service_healthy
nextcloud-redis:
condition: service_healthy
# Runs Nextcloud background jobs on a 5-minute schedule (replaces webcron/ajax cron)
nextcloud-cron:
image: nextcloud:{{ nextcloud_version }}
container_name: nextcloud-cron
restart: unless-stopped
entrypoint: /cron.sh
volumes:
- {{ nextcloud_data_dir }}/html:/var/www/html
networks:
- nextcloud_internal
depends_on:
- nextcloud
networks:
traefik_public:
external: true
nextcloud_internal:
internal: true
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart registry
community.docker.docker_compose_v2:
project_src: "{{ registry_data_dir }}"
state: present
pull: missing
recreate: always
+40 -1
View File
@@ -1,2 +1,41 @@
--- ---
# TODO: implement registry role - name: Create registry data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ registry_data_dir }}"
- "{{ registry_data_dir }}/data"
- "{{ registry_data_dir }}/auth"
tags: registry
# htpasswd content is stored in vault and deployed as a file
- name: Deploy htpasswd file
ansible.builtin.copy:
content: "{{ registry_htpasswd }}\n"
dest: "{{ registry_data_dir }}/auth/htpasswd"
owner: root
group: root
mode: "0600"
notify: Restart registry
tags: registry
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ registry_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart registry
tags: registry
- name: Start registry
community.docker.docker_compose_v2:
project_src: "{{ registry_data_dir }}"
state: present
pull: missing
tags: registry
@@ -0,0 +1,29 @@
# Managed by Ansible — do not edit manually
services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Container Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- {{ registry_data_dir }}/data:/var/lib/registry
- {{ registry_data_dir }}/auth:/auth:ro
networks:
- traefik_public
labels:
- "traefik.enable=true"
- "traefik.http.routers.registry.rule=Host(`{{ domain_registry }}`)"
- "traefik.http.routers.registry.entrypoints=websecure"
- "traefik.http.routers.registry.tls.certresolver=letsencrypt"
- "traefik.http.services.registry.loadbalancer.server.port=5000"
- "traefik.http.routers.registry.middlewares=registry-buffering"
# Remove body size limit so large image layers can be pushed
- "traefik.http.middlewares.registry-buffering.buffering.maxRequestBodyBytes=0"
networks:
traefik_public:
external: true
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart sso
community.docker.docker_compose_v2:
project_src: "{{ sso_data_dir }}"
state: present
pull: missing
recreate: always
+28 -1
View File
@@ -1,2 +1,29 @@
--- ---
# TODO: implement sso role - name: Create Keycloak data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ sso_data_dir }}"
- "{{ sso_data_dir }}/db"
tags: sso
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ sso_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart sso
tags: sso
- name: Start Keycloak
community.docker.docker_compose_v2:
project_src: "{{ sso_data_dir }}"
state: present
pull: missing
tags: sso
@@ -0,0 +1,64 @@
# Managed by Ansible — do not edit manually
services:
sso-db:
image: postgres:{{ keycloak_db_version }}
container_name: sso-db
restart: unless-stopped
environment:
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: "{{ keycloak_db_password }}"
POSTGRES_DB: keycloak
volumes:
- {{ sso_data_dir }}/db:/var/lib/postgresql/data
networks:
- sso_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
sso-keycloak:
image: quay.io/keycloak/keycloak:{{ keycloak_version }}
container_name: sso-keycloak
command: start
restart: unless-stopped
environment:
# Database
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://sso-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: "{{ keycloak_db_password }}"
# Hostname — Traefik handles TLS, Keycloak listens plain HTTP internally
KC_HOSTNAME: "{{ domain_sso }}"
KC_HTTP_ENABLED: "true"
KC_PROXY_HEADERS: xforwarded
# Health endpoint (used by Docker healthcheck)
KC_HEALTH_ENABLED: "true"
# Bootstrap admin — only used on first start; change password via UI afterwards
KEYCLOAK_ADMIN: "{{ keycloak_admin_user }}"
KEYCLOAK_ADMIN_PASSWORD: "{{ keycloak_admin_password }}"
networks:
- traefik_public
- sso_internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.sso.rule=Host(`{{ domain_sso }}`)"
- "traefik.http.routers.sso.entrypoints=websecure"
- "traefik.http.routers.sso.tls.certresolver=letsencrypt"
- "traefik.http.services.sso.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
depends_on:
sso-db:
condition: service_healthy
networks:
traefik_public:
external: true
sso_internal:
internal: true
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart traefik
community.docker.docker_compose_v2:
project_src: "{{ traefik_data_dir }}"
state: present
pull: missing
recreate: always
+47 -1
View File
@@ -1,2 +1,48 @@
--- ---
# TODO: implement traefik role - name: Create Traefik data directory
ansible.builtin.file:
path: "{{ traefik_data_dir }}"
state: directory
owner: root
group: root
mode: "0750"
tags: traefik
# acme.json must be 0600 or Traefik refuses to start
- name: Create acme.json for certificate storage
ansible.builtin.file:
path: "{{ traefik_data_dir }}/acme.json"
state: touch
owner: root
group: root
mode: "0600"
modification_time: preserve
access_time: preserve
tags: traefik
- name: Deploy Traefik static config
ansible.builtin.template:
src: traefik.yml.j2
dest: "{{ traefik_data_dir }}/traefik.yml"
owner: root
group: root
mode: "0644"
notify: Restart traefik
tags: traefik
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ traefik_data_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
notify: Restart traefik
tags: traefik
- name: Start Traefik
community.docker.docker_compose_v2:
project_src: "{{ traefik_data_dir }}"
state: present
pull: missing
tags: traefik
@@ -0,0 +1,28 @@
# Managed by Ansible — do not edit manually
services:
traefik:
image: traefik:{{ traefik_version }}
container_name: traefik
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- {{ traefik_data_dir }}/traefik.yml:/traefik.yml:ro
- {{ traefik_data_dir }}/acme.json:/acme.json
networks:
- traefik_public
labels:
- "traefik.enable=true"
# Dashboard — accessible at cloud.ladkau.de/dashboard/ protected by basic auth
- "traefik.http.routers.dashboard.rule=Host(`{{ domain_cloud }}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=dashboard-auth"
- "traefik.http.middlewares.dashboard-auth.basicauth.users={{ traefik_dashboard_users }}"
networks:
traefik_public:
external: true
@@ -0,0 +1,37 @@
# Managed by Ansible — do not edit manually
global:
checkNewVersion: false
sendAnonymousUsage: false
api:
dashboard: true
log:
level: INFO
accessLog: {}
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: "{{ traefik_network }}"
certificatesResolvers:
letsencrypt:
acme:
email: "{{ acme_email }}"
storage: /acme.json
httpChallenge:
entryPoint: web
+71 -2
View File
@@ -46,7 +46,76 @@ chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys chmod 600 /home/deploy/.ssh/authorized_keys
``` ```
### 4. Run the Ansible master playbook ### 4. Set secrets (Ansible Vault)
Before running the playbook, populate secrets that must not be committed in plain text.
Create `ansible/group_vars/vault.yml` (gitignored) and encrypt it with Ansible Vault:
```bash
ansible-vault create ansible/group_vars/vault.yml
```
Required secrets:
```yaml
# Traefik dashboard basic auth — generate with:
# echo $(htpasswd -nB admin) | sed -e 's/\$/\$\$/g'
traefik_dashboard_users: "admin:$$2y$$05$$..."
# Gitea — generate secrets with: openssl rand -hex 32
gitea_db_password: ""
gitea_secret_key: ""
gitea_internal_token: ""
# Keycloak
keycloak_db_password: ""
keycloak_admin_password: ""
# Nextcloud
nextcloud_db_password: ""
nextcloud_admin_password: ""
# Roundcube — des_key must be exactly 24 characters: openssl rand -hex 12
roundcube_db_password: ""
roundcube_des_key: ""
# Container registry — generate with:
# docker run --entrypoint htpasswd httpd:2 -Bbn <user> <password>
registry_htpasswd: "user:$2y$05$..."
```
**Registry usage after deploy:**
```bash
# Login
docker login cr.ladkau.de
# Push an image
docker tag myimage:latest cr.ladkau.de/myimage:latest
docker push cr.ladkau.de/myimage:latest
# Pull an image
docker pull cr.ladkau.de/myimage:latest
```
**Gitea first-run note:** `gitea_disable_registration` defaults to `true` in
`group_vars/all.yml`. For the very first deploy, override it to `false` in
`vault.yml` so the Gitea setup wizard can create the admin account. After
the admin account exists, remove the override and redeploy.
**Keycloak first-run note:** The `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD`
environment variables bootstrap the initial admin account on first start only.
After logging in at `https://sso.ladkau.de`, change the admin password via the
Keycloak UI and remove the `keycloak_admin_password` reference from vault (or
rotate it). Keycloak ignores these env vars once the admin account already exists.
To run the playbook with vault:
```bash
ansible-playbook -i ansible/inventory.ini ansible/site.yml --ask-vault-pass
```
### 5. Run the Ansible master playbook
From the repo root: From the repo root:
@@ -65,7 +134,7 @@ This runs all roles in order:
8. `registry` — container registry 8. `registry` — container registry
9. `k8s` — k3s node 9. `k8s` — k3s node
### 5. DNS ### 6. DNS
Ensure the following DNS A records point to `217.154.207.148` before running: Ensure the following DNS A records point to `217.154.207.148` before running: