diff --git a/.gitignore b/.gitignore index 68819b8..354fe3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ keys/* !keys/*.* +# Ansible Vault secrets — never commit plain-text secrets +ansible/group_vars/vault.yml + diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index d968bbe..caa793a 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -20,3 +20,67 @@ timezone: Europe/Berlin # Docker network shared by all services and Traefik 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 +# 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 diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 11e9338..b5ab1d5 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -2,3 +2,4 @@ collections: - name: community.general # ufw, timezone modules - name: ansible.posix # authorized_key module + - name: community.docker # docker_network module diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000..07aa0eb --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml index a9f7360..b1eadb2 100644 --- a/ansible/roles/docker/tasks/main.yml +++ b/ansible/roles/docker/tasks/main.yml @@ -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 diff --git a/ansible/roles/docker/templates/daemon.json.j2 b/ansible/roles/docker/templates/daemon.json.j2 new file mode 100644 index 0000000..b8ab8c9 --- /dev/null +++ b/ansible/roles/docker/templates/daemon.json.j2 @@ -0,0 +1,7 @@ +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } +} diff --git a/ansible/roles/gitea/handlers/main.yml b/ansible/roles/gitea/handlers/main.yml new file mode 100644 index 0000000..8d66cf6 --- /dev/null +++ b/ansible/roles/gitea/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart gitea + community.docker.docker_compose_v2: + project_src: "{{ gitea_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/gitea/tasks/main.yml b/ansible/roles/gitea/tasks/main.yml index 44c3e55..b636229 100644 --- a/ansible/roles/gitea/tasks/main.yml +++ b/ansible/roles/gitea/tasks/main.yml @@ -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 diff --git a/ansible/roles/gitea/templates/docker-compose.yml.j2 b/ansible/roles/gitea/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c295fc5 --- /dev/null +++ b/ansible/roles/gitea/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/k8s/files/index.html b/ansible/roles/k8s/files/index.html new file mode 100644 index 0000000..b9fcb01 --- /dev/null +++ b/ansible/roles/k8s/files/index.html @@ -0,0 +1,34 @@ + + + + + + k8s.ladkau.de + + + +
+

k8s.ladkau.de

+
+ + diff --git a/ansible/roles/k8s/handlers/main.yml b/ansible/roles/k8s/handlers/main.yml new file mode 100644 index 0000000..9bb5896 --- /dev/null +++ b/ansible/roles/k8s/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart k8s + community.docker.docker_compose_v2: + project_src: "{{ k8s_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/k8s/tasks/main.yml b/ansible/roles/k8s/tasks/main.yml index d61459d..f7dfad3 100644 --- a/ansible/roles/k8s/tasks/main.yml +++ b/ansible/roles/k8s/tasks/main.yml @@ -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 diff --git a/ansible/roles/k8s/templates/docker-compose.yml.j2 b/ansible/roles/k8s/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f86e3f1 --- /dev/null +++ b/ansible/roles/k8s/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/mail/handlers/main.yml b/ansible/roles/mail/handlers/main.yml new file mode 100644 index 0000000..ca4c7f1 --- /dev/null +++ b/ansible/roles/mail/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart mail + community.docker.docker_compose_v2: + project_src: "{{ mail_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/mail/tasks/main.yml b/ansible/roles/mail/tasks/main.yml index f8a70ba..d3d01d5 100644 --- a/ansible/roles/mail/tasks/main.yml +++ b/ansible/roles/mail/tasks/main.yml @@ -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 diff --git a/ansible/roles/mail/templates/docker-compose.yml.j2 b/ansible/roles/mail/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..3f3a131 --- /dev/null +++ b/ansible/roles/mail/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/nextcloud/handlers/main.yml b/ansible/roles/nextcloud/handlers/main.yml new file mode 100644 index 0000000..ecbcd25 --- /dev/null +++ b/ansible/roles/nextcloud/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart nextcloud + community.docker.docker_compose_v2: + project_src: "{{ nextcloud_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/nextcloud/tasks/main.yml b/ansible/roles/nextcloud/tasks/main.yml index 3edefc3..59ca274 100644 --- a/ansible/roles/nextcloud/tasks/main.yml +++ b/ansible/roles/nextcloud/tasks/main.yml @@ -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 diff --git a/ansible/roles/nextcloud/templates/docker-compose.yml.j2 b/ansible/roles/nextcloud/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c3cc257 --- /dev/null +++ b/ansible/roles/nextcloud/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/registry/handlers/main.yml b/ansible/roles/registry/handlers/main.yml new file mode 100644 index 0000000..2339017 --- /dev/null +++ b/ansible/roles/registry/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart registry + community.docker.docker_compose_v2: + project_src: "{{ registry_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/registry/tasks/main.yml b/ansible/roles/registry/tasks/main.yml index efcfc32..4f45d3c 100644 --- a/ansible/roles/registry/tasks/main.yml +++ b/ansible/roles/registry/tasks/main.yml @@ -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 diff --git a/ansible/roles/registry/templates/docker-compose.yml.j2 b/ansible/roles/registry/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..7a565f8 --- /dev/null +++ b/ansible/roles/registry/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/sso/handlers/main.yml b/ansible/roles/sso/handlers/main.yml new file mode 100644 index 0000000..79b0a0b --- /dev/null +++ b/ansible/roles/sso/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart sso + community.docker.docker_compose_v2: + project_src: "{{ sso_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/sso/tasks/main.yml b/ansible/roles/sso/tasks/main.yml index 39a7c18..454fcb9 100644 --- a/ansible/roles/sso/tasks/main.yml +++ b/ansible/roles/sso/tasks/main.yml @@ -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 diff --git a/ansible/roles/sso/templates/docker-compose.yml.j2 b/ansible/roles/sso/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..e6bda3a --- /dev/null +++ b/ansible/roles/sso/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/traefik/handlers/main.yml b/ansible/roles/traefik/handlers/main.yml new file mode 100644 index 0000000..a3c4f18 --- /dev/null +++ b/ansible/roles/traefik/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart traefik + community.docker.docker_compose_v2: + project_src: "{{ traefik_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/traefik/tasks/main.yml b/ansible/roles/traefik/tasks/main.yml index 0d2b6df..874fc49 100644 --- a/ansible/roles/traefik/tasks/main.yml +++ b/ansible/roles/traefik/tasks/main.yml @@ -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 diff --git a/ansible/roles/traefik/templates/docker-compose.yml.j2 b/ansible/roles/traefik/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..3f7ca37 --- /dev/null +++ b/ansible/roles/traefik/templates/docker-compose.yml.j2 @@ -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 diff --git a/ansible/roles/traefik/templates/traefik.yml.j2 b/ansible/roles/traefik/templates/traefik.yml.j2 new file mode 100644 index 0000000..e09fd07 --- /dev/null +++ b/ansible/roles/traefik/templates/traefik.yml.j2 @@ -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 diff --git a/docs/runbook.md b/docs/runbook.md index 3150b76..6ffc824 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -46,7 +46,76 @@ chmod 700 /home/deploy/.ssh 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 +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: @@ -65,7 +134,7 @@ This runs all roles in order: 8. `registry` — container registry 9. `k8s` — k3s node -### 5. DNS +### 6. DNS Ensure the following DNS A records point to `217.154.207.148` before running: