- 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