- name: Temporary workaround for sudo-rs hosts: nextcloud gather_facts: false become: false tasks: - name: Detect sudo variant ansible.builtin.raw: command -v sudo.ws || true register: sudo_ws_check changed_when: false - name: Set become_exe fact based on sudo variant present ansible.builtin.set_fact: ansible_become_exe: "{{ 'sudo.ws' if sudo_ws_check.stdout | trim | length > 0 else 'sudo' }}" # ================================================================ # PLAY 0 — Bootstrap: install acl BEFORE any become_user is used. # ================================================================ - name: Bootstrap – ensure acl is installed hosts: nextcloud gather_facts: true become: true tasks: - name: Update apt cache ansible.builtin.apt: update_cache: true cache_valid_time: 3600 - name: Install acl (required for Ansible become_user on Linux) ansible.builtin.apt: name: acl state: present # ================================================================ # PLAY 1 — Main Nextcloud installation # ================================================================ - name: Install and configure Nextcloud hosts: nextcloud gather_facts: true become: true vars_files: - vars/shared_vars.yml # --------------------------------------------------------------- # 1. System packages # --------------------------------------------------------------- tasks: - name: Install system packages ansible.builtin.apt: name: - apt-transport-https - ca-certificates - curl - gnupg - lsb-release - python3-pymysql - openssl - unzip - bzip2 state: present # --------------------------------------------------------------- # 2. Apache # --------------------------------------------------------------- - name: Install Apache2 ansible.builtin.apt: name: apache2 state: present - name: Enable required Apache modules community.general.apache2_module: name: "{{ item }}" state: present loop: - rewrite - headers - env - dir - mime - ssl notify: Restart Apache - name: Enable Apache service ansible.builtin.systemd: name: apache2 enabled: true state: started # --------------------------------------------------------------- # 3. Self-signed TLS certificate # --------------------------------------------------------------- - name: Generate self-signed TLS certificate (10-year validity) ansible.builtin.command: cmd: > openssl req -x509 -nodes -days 3650 -newkey rsa:4096 -keyout {{ nc_ssl_key }} -out {{ nc_ssl_cert }} -subj "/CN={{ nextcloud_domain }}/O=Nextcloud/C=DE" -addext "subjectAltName=DNS:{{ nextcloud_domain }}" creates: "{{ nc_ssl_cert }}" - name: Restrict private key permissions ansible.builtin.file: path: "{{ nc_ssl_key }}" owner: root group: root mode: "0600" # --------------------------------------------------------------- # 4. PHP + extensions # --------------------------------------------------------------- # # Ubuntu 24.04 ships PHP 8.3, Ubuntu 26.04 ships PHP 8.5. Two # packaging changes had to be accounted for between these releases: # # 1. php{v}-opcache no longer exists from PHP 8.5 onward — opcache # is compiled into core/-common. Omit that package name on 8.5+. # # 2. libapache2-mod-php{v} was missing from the original package # list entirely. Without it, Apache has no PHP handler and none # of the /etc/php/{v}/apache2/ SAPI files (php.ini, conf.d/) # get created by the packaging scripts in the first place — # this is what caused the opcache.ini blockinfile task to have # nothing to attach to. Added explicitly below. # - name: Build PHP package list ansible.builtin.set_fact: nc_php_packages: >- {{ [ 'php{v}', 'php{v}-cli', 'php{v}-common', 'libapache2-mod-php{v}', 'php{v}-curl', 'php{v}-gd', 'php{v}-gmp', 'php{v}-imagick', 'php{v}-intl', 'php{v}-mbstring', 'php{v}-mysql', 'php{v}-readline', 'php{v}-redis', 'php{v}-xml', 'php{v}-zip', 'php{v}-bcmath', 'php{v}-apcu' ] | map('replace', '{v}', nc_php_version) | list + (['php{v}-opcache'.replace('{v}', nc_php_version)] if nc_php_version is version('8.5', '<') else []) }} - name: Install PHP {{ nc_php_version }} and extensions ansible.builtin.apt: name: "{{ nc_php_packages }}" state: present notify: Restart Apache - name: Ensure PHP Apache SAPI conf.d directory exists ansible.builtin.file: path: "/etc/php/{{ nc_php_version }}/apache2/conf.d" state: directory owner: root group: root mode: "0755" - name: Configure PHP for Nextcloud (php.ini tweaks) ansible.builtin.lineinfile: path: "/etc/php/{{ nc_php_version }}/apache2/php.ini" regexp: "{{ item.regexp }}" line: "{{ item.line }}" backup: true loop: - { regexp: '^memory_limit', line: 'memory_limit = 2048M' } - { regexp: '^upload_max_filesize', line: 'upload_max_filesize = 16G' } - { regexp: '^post_max_size', line: 'post_max_size = 16G' } - { regexp: '^max_execution_time', line: 'max_execution_time = 300' } - { regexp: '^max_input_time', line: 'max_input_time = 300' } - { regexp: '^output_buffering', line: 'output_buffering = Off' } notify: Restart Apache - name: Enable OPcache settings ansible.builtin.blockinfile: path: "/etc/php/{{ nc_php_version }}/apache2/conf.d/10-opcache.ini" block: | opcache.enable=1 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=10000 opcache.memory_consumption=128 opcache.save_comments=1 opcache.revalidate_freq=1 marker: "; {mark} ANSIBLE MANAGED BLOCK" notify: Restart Apache # --------------------------------------------------------------- # 5. MariaDB # --------------------------------------------------------------- # # Note: Ubuntu 26.04 moves MariaDB's data directory from # /var/lib/mysql to /var/lib/mariadb, and mariadb-server becomes # a thin compatibility metapackage over the "mariadb" packaging. # Neither of these changes affects this playbook: we never # reference the data directory path directly, and the client # socket path (/var/run/mysqld/mysqld.sock) is unchanged across # 24.04–26.04, so login_unix_socket below still works as-is. # - name: Install MariaDB server ansible.builtin.apt: name: - mariadb-server - mariadb-client state: present - name: Enable and start MariaDB ansible.builtin.systemd: name: mariadb enabled: true state: started - name: Create Nextcloud database community.mysql.mysql_db: name: "{{ nc_db_name }}" collation: utf8mb4_general_ci encoding: utf8mb4 state: present login_unix_socket: /var/run/mysqld/mysqld.sock - name: Create Nextcloud database user community.mysql.mysql_user: name: "{{ nc_db_user }}" password: "{{ nc_db_password }}" priv: "{{ nc_db_name }}.*:ALL" host: localhost state: present login_unix_socket: /var/run/mysqld/mysqld.sock # --------------------------------------------------------------- # 6. Download & extract Nextcloud # --------------------------------------------------------------- - name: Check if Nextcloud is already present ansible.builtin.stat: path: "{{ nextcloud_install_dir }}/index.php" register: nextcloud_stat - name: Download Nextcloud {{ nextcloud_version }} ansible.builtin.get_url: url: "https://download.nextcloud.com/server/releases/nextcloud-{{ nextcloud_version }}.zip" dest: "/tmp/nextcloud-{{ nextcloud_version }}.zip" mode: "0644" when: not nextcloud_stat.stat.exists - name: Extract Nextcloud archive ansible.builtin.unarchive: src: "/tmp/nextcloud-{{ nextcloud_version }}.zip" dest: /etc/ remote_src: true owner: www-data group: www-data when: not nextcloud_stat.stat.exists - name: Create Nextcloud data directory ansible.builtin.file: path: "{{ nextcloud_data_dir }}" state: directory owner: www-data group: www-data mode: "0750" - name: Set correct ownership on Nextcloud install directory ansible.builtin.file: path: "{{ nextcloud_install_dir }}" recurse: true owner: www-data group: www-data # --------------------------------------------------------------- # 7. Apache virtual host — HTTP redirects to HTTPS, HTTPS serves NC # --------------------------------------------------------------- - name: Deploy Nextcloud Apache virtual host (HTTP redirect + HTTPS) ansible.builtin.copy: dest: /etc/apache2/sites-available/nextcloud.conf mode: "0644" content: | # ── HTTP: redirect all traffic to HTTPS ────────────────────── ServerName {{ nextcloud_domain }} RewriteEngine On RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L] # ── HTTPS: serve Nextcloud ──────────────────────────────────── ServerName {{ nextcloud_domain }} DocumentRoot {{ nextcloud_install_dir }} SSLEngine on SSLCertificateFile {{ nc_ssl_cert }} SSLCertificateKeyFile {{ nc_ssl_key }} # Recommended TLS hardening SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLCipherSuite ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:!aNULL:!MD5:!DSS SSLHonorCipherOrder on Header always set Strict-Transport-Security "max-age=15552000; includeSubDomains" Require all granted AllowOverride All Options FollowSymLinks MultiViews Dav off # Security headers Header always set Strict-Transport-Security "max-age=15552000; includeSubDomains" Header always set Referrer-Policy "no-referrer" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Permitted-Cross-Domain-Policies "none" Header always set X-Robots-Tag "noindex, nofollow" Header always set X-XSS-Protection "1; mode=block" ErrorLog ${APACHE_LOG_DIR}/nextcloud_error.log CustomLog ${APACHE_LOG_DIR}/nextcloud_access.log combined notify: Restart Apache - name: Disable default Apache site ansible.builtin.command: a2dissite 000-default args: removes: /etc/apache2/sites-enabled/000-default.conf notify: Restart Apache - name: Enable Nextcloud Apache site ansible.builtin.command: a2ensite nextcloud args: creates: /etc/apache2/sites-enabled/nextcloud.conf notify: Restart Apache # --------------------------------------------------------------- # 8. Nextcloud occ installer # --------------------------------------------------------------- - name: Check if Nextcloud is already installed ansible.builtin.stat: path: "{{ nextcloud_install_dir }}/config/config.php" register: nc_config - name: Run Nextcloud installation via occ ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ maintenance:install \ --database "mysql" \ --database-name "{{ nc_db_name }}" \ --database-user "{{ nc_db_user }}" \ --database-pass "{{ nc_db_password }}" \ --admin-user "{{ nc_admin_user }}" \ --admin-pass "{{ nc_admin_password }}" \ --data-dir "{{ nextcloud_data_dir }}"' args: creates: "{{ nextcloud_install_dir }}/config/config.php" register: occ_install - name: Add trusted domain to Nextcloud config ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ config:system:set \ trusted_domains 0 --value="{{ nextcloud_domain }}"' when: occ_install.changed - name: Set overwrite.cli.url to HTTPS ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ config:system:set \ overwrite.cli.url --value="https://{{ nextcloud_domain }}"' when: occ_install.changed - name: Force HTTPS protocol in Nextcloud ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ config:system:set \ overwriteprotocol --value="https"' when: occ_install.changed - name: Set default phone region ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ \ config:system:set default_phone_region --value=DE' when: occ_install.changed - name: Set maintenance window start hour (UTC) ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ config:system:set \ nc_maintenance_window_start --type=integer --value="{{ nc_maintenance_window_start }}"' when: occ_install.changed # --------------------------------------------------------------- # 9. Cron job for background tasks # --------------------------------------------------------------- - name: Configure Nextcloud background job to use cron ansible.builtin.shell: | su -s /bin/bash www-data -c \ 'php {{ nextcloud_install_dir }}/occ background:cron' - name: Add cron job for Nextcloud ansible.builtin.cron: name: "Nextcloud background tasks" user: www-data minute: "*/5" job: "php -f {{ nextcloud_install_dir }}/cron.php > /dev/null 2>&1" # --------------------------------------------------------------- # Handlers # --------------------------------------------------------------- handlers: - name: Restart Apache ansible.builtin.systemd: name: apache2 state: restarted # --------------------------------------------------------------- # Play 3 - Nextcloud: Hardening # --------------------------------------------------------------- - name: Harden Nextcloud install hosts: nextcloud gather_facts: true become: true vars_files: - vars/shared_vars.yml tasks: - name: Set correct open_basedir restriction ansible.builtin.lineinfile: path: "/etc/php/{{ nc_php_version }}/apache2/php.ini" regexp: '^;?open_basedir' line: "open_basedir = {{ nextcloud_install_dir }}:{{ nextcloud_data_dir }}:/tmp:/dev/urandom" notify: Restart Apache - name: Allow Apache AppArmor profile to write to Nextcloud config ansible.builtin.blockinfile: path: /etc/apparmor.d/local/usr.sbin.apache2 create: true owner: root group: root mode: "0644" block: | {{ nextcloud_install_dir }}/ r, {{ nextcloud_install_dir }}/** rw, {{ nextcloud_data_dir }}/ r, {{ nextcloud_data_dir }}/** rw, notify: Reload AppArmor # --------------------------------------------------------------- # Setup fail2ban for Nextcloud # --------------------------------------------------------------- - name: Install fail2ban ansible.builtin.apt: name: - fail2ban state: present - name: Alter Nextcloud log settings for fail2ban ansible.builtin.lineinfile: path: "{{ nextcloud_install_dir }}/config/config.php" regexp: "{{ item.regexp }}" line: " {{ item.line }}" insertbefore: '^\);' backup: true loop: - { regexp: "^\\s*'log_type'\\s*=>", line: "'log_type' => 'file'," } - { regexp: "^\\s*'logfile'\\s*=>", line: "'logfile' => '{{ nextcloud_data_dir }}/nextcloud.log'," } - { regexp: "^\\s*'loglevel'\\s*=>", line: "'loglevel' => 3," } - name: Create fail2ban filter for Nextcloud ansible.builtin.copy: content: | {% raw %} [Definition] _groupsre = (?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*) failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Login failed: ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Two-factor challenge failed: ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Trusted domain error. datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%d[T ]%%H:%%M:%%S(%%z)?" {% endraw %} dest: "{{ fail2ban_dir }}/filter.d/nextcloud.conf" mode: "0644" - name: Create fail2ban jail for Nextcloud ansible.builtin.copy: dest: "{{ fail2ban_dir }}/jail.d/nextcloud.local" mode: "0644" content: | [nextcloud] backend = auto enabled = true port = 80,443 protocol = tcp filter = nextcloud maxretry = 3 bantime = 86400 findtime = 43200 logpath = {{ nextcloud_data_dir }}/nextcloud.log notify: Restart Apache # --------------------------------------------------------------- # Handlers # --------------------------------------------------------------- handlers: - name: Restart Apache ansible.builtin.systemd: name: apache2 state: restarted - name: Reload AppArmor ansible.builtin.command: apparmor_parser -r /etc/apparmor.d/usr.sbin.apache2 args: removes: /etc/apparmor.d/usr.sbin.apache2 # This file was written by Ebbe Baß (umpi) - ebbe@ping-mee.de