--- # de Byl Technologies Nextcloud (cloud.debyltech.com). # # Cloned from containers/skudak/cloud.yml, which carries the full reasoning for # nearly every task below -- read the matching comment there before changing # one here. Comments in this file cover only where the two instances differ. # # Differences from Skudak, by design: # - Fresh install: NEXTCLOUD_ADMIN_* makes the first deploy install # unattended, and LibreSign is installed from the app store rather than # assumed present. # - No Group Folders. Registration stays off, matching Skudak's live state: # every account, staff and customer alike, is created by the admin, with # customers in per-customer groups. # - Outbound mail is AWS SES SMTP (noreply@debyltech.com), set here via occ # so it lives in git rather than only in the admin UI. # - Backups go to personal iDrive e2 via TrueNAS -- see the backup include # at the bottom. - name: create required debyltech cloud volumes become: true ansible.builtin.file: path: "{{ item }}" state: directory owner: "{{ podman_subuid.stdout }}" group: "{{ podman_subuid.stdout }}" mode: 0755 notify: restorecon podman loop: - "{{ cloud_debyltech_path }}/apps" - "{{ cloud_debyltech_path }}/config" - "{{ cloud_debyltech_path }}/data" - "{{ cloud_debyltech_path }}/mysql" - "{{ cloud_debyltech_path }}/scripts" - "{{ cloud_debyltech_path }}/redis" - name: unshare chown the debyltech cloud volumes become: true become_user: "{{ podman_user }}" changed_when: false ansible.builtin.command: | podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps {{ cloud_debyltech_path }}/data {{ cloud_debyltech_path }}/config - name: flush handlers ansible.builtin.meta: flush_handlers - import_tasks: podman/podman-check.yml vars: container_name: debyltech-cloud-db container_image: "{{ db_image }}" - name: create debyltech-cloud-db container become: true become_user: "{{ podman_user }}" containers.podman.podman_container: name: debyltech-cloud-db image: "{{ db_image }}" restart_policy: on-failure:3 log_driver: journald network: - shared env: MYSQL_ROOT_PASSWORD: "{{ cloud_debyltech_db_root_pass }}" MYSQL_DATABASE: dtcloud MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}" MYSQL_USER: dtcloud volumes: - "{{ cloud_debyltech_path }}/mysql:/var/lib/mysql" - name: create systemd startup job for debyltech-cloud-db include_tasks: podman/systemd-generate.yml vars: container_name: debyltech-cloud-db # --------------------------------------------------------------------------- # Redis: distributed cache + file locking. MUST exist before debyltech-cloud # below -- see the Skudak equivalent for why. - name: template debyltech cloud redis config become: true ansible.builtin.template: src: nextcloud/redis-debyltech.conf.j2 dest: "{{ cloud_debyltech_path }}/redis/redis.conf" owner: "{{ podman_subuid.stdout }}" group: "{{ podman_subuid.stdout }}" mode: 0640 notify: restorecon podman no_log: true - name: flush handlers ansible.builtin.meta: flush_handlers - name: unshare chown the debyltech redis config to the redis uid become: true become_user: "{{ podman_user }}" changed_when: false ansible.builtin.command: > podman unshare chown 999:1000 {{ cloud_debyltech_path }}/redis/redis.conf - import_tasks: podman/podman-check.yml vars: container_name: debyltech-cloud-redis container_image: "{{ redis_image }}" - name: create debyltech-cloud-redis container become: true become_user: "{{ podman_user }}" containers.podman.podman_container: name: debyltech-cloud-redis image: "{{ redis_image }}" restart_policy: on-failure:3 log_driver: journald network: - shared volumes: - "{{ cloud_debyltech_path }}/redis/redis.conf:/etc/redis/redis.conf:ro" command: redis-server /etc/redis/redis.conf - name: create systemd startup job for debyltech-cloud-redis include_tasks: podman/systemd-generate.yml vars: container_name: debyltech-cloud-redis - import_tasks: podman/podman-check.yml vars: container_name: debyltech-cloud container_image: "{{ image }}" - name: create debyltech cloud container become: true become_user: "{{ podman_user }}" containers.podman.podman_container: name: debyltech-cloud image: "{{ image }}" restart_policy: on-failure:3 log_driver: journald network: - shared env: MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}" MYSQL_DATABASE: dtcloud MYSQL_HOST: debyltech-cloud-db MYSQL_USER: dtcloud # Read by the entrypoint ONLY on first start against an empty config # volume, to run the install unattended; ignored on every start after. NEXTCLOUD_ADMIN_USER: admin NEXTCLOUD_ADMIN_PASSWORD: "{{ cloud_debyltech_admin_pass }}" NEXTCLOUD_TRUSTED_DOMAINS: "{{ cloud_debyltech_server_name }}" PHP_MEMORY_LIMIT: 1024M PHP_UPLOAD_LIMIT: 512M LC_ALL: C.UTF-8 LANG: C.UTF-8 REDIS_HOST: debyltech-cloud-redis REDIS_HOST_PORT: "6379" REDIS_HOST_PASSWORD: "{{ cloud_debyltech_redis_pass }}" volumes: - "{{ cloud_debyltech_path }}/apps:/var/www/html/custom_apps" - "{{ cloud_debyltech_path }}/data:/var/www/html/data" - "{{ cloud_debyltech_path }}/config:/var/www/html/config" ports: - "8091:80" - name: create systemd startup job for debyltech-cloud include_tasks: podman/systemd-generate.yml vars: container_name: debyltech-cloud # --------------------------------------------------------------------------- # LibreSign - name: install libresign runtime dependencies in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: cmd: > podman exec -u 0 debyltech-cloud sh -c "apt-get update && apt-get install -y --no-install-recommends poppler-utils ghostscript && rm -rf /var/lib/apt/lists/*" register: libresign_deps changed_when: "'is already the newest version' not in libresign_deps.stdout" # On the FIRST deploy this also waits out the unattended install, which takes # noticeably longer than a restart -- hence the larger budget than Skudak's. - name: wait for nextcloud to be ready in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ status --output=json register: debyltech_occ_ready # String match, not from_json: before the install finishes occ can print a # plain-text warning ahead of the JSON, and a parse error would abort the # retry loop instead of waiting. Skudak's bare 'installed' check would also # match "installed":false, which is exactly the state being waited out here. until: >- debyltech_occ_ready.rc == 0 and '"installed":true' in debyltech_occ_ready.stdout retries: 60 delay: 5 changed_when: false # LibreSign is PINNED (libresign_version / libresign_sha256 in tasks/main.yml) # and installed from the upstream GitHub release, NOT `occ app:install`, which # always takes whatever the app store has that day. On 2026-09-28 that was a # same-day 14.2.3 whose tarball shipped without appinfo/install-*.json -- the # maintainer-signed metadata LibreSign verifies its java/pdftk/jsignpdf # downloads against -- so configure:check failed all three on a clean install. # # Upgrading: bump both pins together (the sha256 is on the GitHub release # asset) and deploy; the tree is replaced and `occ upgrade` runs the app's # migrations. Downgrading is refused below: Nextcloud does not support it, and # the only way back is removing the app, which discards its config and CA. - name: read installed libresign version in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:get libresign installed_version register: libresign_installed changed_when: false failed_when: false - name: refuse to downgrade libresign in debyltech-cloud ansible.builtin.fail: msg: >- LibreSign {{ libresign_installed.stdout }} is installed but the pin is {{ libresign_version }}. Nextcloud cannot downgrade an app in place -- raise the pin, or remove the app deliberately if nothing has been signed. when: - libresign_installed.rc == 0 - libresign_installed.stdout is version(libresign_version, '>') - name: install pinned libresign release in debyltech-cloud when: libresign_installed.rc != 0 or libresign_installed.stdout != libresign_version block: - name: fetch pinned libresign release become: true ansible.builtin.get_url: url: "https://github.com/LibreSign/libresign/releases/download/v{{ libresign_version }}/libresign-v{{ libresign_version }}.tar.gz" dest: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz" checksum: "sha256:{{ libresign_sha256 }}" mode: 0644 - name: remove previous libresign app tree become: true ansible.builtin.file: path: "{{ cloud_debyltech_path }}/apps/libresign" state: absent - name: unpack pinned libresign release into custom_apps become: true ansible.builtin.unarchive: src: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz" dest: "{{ cloud_debyltech_path }}/apps/" remote_src: true # Unpacked as root the files keep the tarball's owners, which lie # outside the podman user's subuid range, so the unshare chown below # is refused. Same two-step as the debyltechmail copy. owner: "{{ podman_subuid.stdout }}" group: "{{ podman_subuid.stdout }}" notify: restorecon podman - name: unshare chown the libresign app tree become: true become_user: "{{ podman_user }}" changed_when: false ansible.builtin.command: > podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps/libresign - name: flush handlers ansible.builtin.meta: flush_handlers # Only an in-place upgrade needs this; a first install is handled by the # app:enable below. - name: run libresign migrations in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ upgrade when: libresign_installed.rc == 0 - name: ensure libresign app is enabled in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ app:enable libresign register: libresign_enable changed_when: "'already enabled' not in libresign_enable.stdout" # 14.2.x's downloader does not create its own target directories: on a fresh # appdata every java/pdftk download fails with "Directory ... does not exist # for sink value". Creating them first is harmless once they exist. - name: pre-create libresign binary directories in debyltech-cloud become: true become_user: "{{ podman_user }}" changed_when: false ansible.builtin.command: > podman exec -u www-data debyltech-cloud sh -c 'd=$(ls -d /var/www/html/data/appdata_*/libresign) && mkdir -p "$d/x86_64/linux/java" "$d/x86_64/pdftk"' # "Finished with success" is printed even when every download failed, so this # check only catches the command itself falling over. The real gate is the # configure:check verify task below, which hashes each binary against the # release's signed metadata. - name: install libresign java/pdftk/jsignpdf binaries in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ libresign:install --java --pdftk --jsignpdf register: libresign_install changed_when: false failed_when: "'Finished with success' not in libresign_install.stdout" - name: check whether libresign root certificate is configured become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ libresign:configure:check --certificate register: libresign_cert_check changed_when: false failed_when: false # Guarded: re-running would mint a new root CA and orphan every certificate # already issued. No --ou -- see skudak/cloud.yml. - name: generate libresign root certificate for debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ libresign:configure:openssl --cn="{{ libresign_debyltech_cert_cn }}" -o "{{ libresign_debyltech_cert_o }}" -c "{{ libresign_debyltech_cert_c }}" -s "{{ libresign_debyltech_cert_st }}" -l "{{ libresign_debyltech_cert_l }}" when: "'error' in libresign_cert_check.stdout" changed_when: true # Signers are mostly customers WITHOUT an account, reached by emailed # invitation; the ID-document gate would leave them unable to sign at all. - name: relax libresign identification-document gate in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:set libresign identification_documents --value=0 register: libresign_ident changed_when: "'is now set to' in libresign_ident.stdout" # Must be exactly GRAPHIC_ONLY -- see skudak/cloud.yml. - name: use signature-only stamp in debyltech-cloud libresign become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:set libresign signature_render_mode --value=GRAPHIC_ONLY register: libresign_render changed_when: "'is now set to' in libresign_render.stdout" # Lets account-owned emails be added as signers. NEVER set the _email variant # of this key to 'no' -- see skudak/cloud.yml. - name: allow account-owned emails as libresign signers in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:set core shareapi_restrict_user_enumeration_full_match --value=no register: debyltech_enum_fullmatch changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout" - name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:set libresign write_qrcode_on_footer --value=0 --type=boolean register: libresign_qr changed_when: "'is now set to' in libresign_qr.stdout" - name: verify libresign configuration in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ libresign:configure:check register: libresign_verify changed_when: false # Double backslashes: Jinja unescapes string literals, so a single '\b' # becomes a BACKSPACE character and this could never match -- which is # how a check reporting three errors passed clean on 2026-09-28. failed_when: libresign_verify.stdout is search('\\berror\\b') # --------------------------------------------------------------------------- # Background jobs. A fresh install defaults to AJAX mode, which only runs jobs # while someone has the web UI open -- LibreSign's queued signature mail and # every cleanup job would stall. The cloud-cron timer included below drives # cron.php; this tells Nextcloud to expect it. - name: set debyltech-cloud background jobs to cron become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:set core backgroundjobs_mode --value=cron register: debyltech_bgjobs changed_when: "'is now set to' in debyltech_bgjobs.stdout" - name: disable nextcloud signup link in debyltech-cloud config become: true ansible.builtin.lineinfile: path: "{{ cloud_debyltech_path }}/config/config.php" regexp: "^\\s*'simpleSignUpLink\\.shown'\\s*=>" line: " 'simpleSignUpLink.shown' => false," insertbefore: '^\);' create: false # --------------------------------------------------------------------------- # Branding: debyltechmail, cloned from skudakmail. custom_apps is a persisted # bind mount, so only enabling and config need reasserting. # Owned directly by the HOST uid that rootless podman maps www-data (33) to -- # subuid start + 32, since container uid 1 is the first subuid. Skudak copies # as the subuid and then `podman unshare chown`s, which flips ownership back # and forth so the copy reports changed on every run; here that would also # re-import the theming logos below every time. - name: deploy debyltechmail email-template app to debyltech-cloud become: true ansible.builtin.copy: src: debyltechmail/ dest: "{{ cloud_debyltech_path }}/apps/debyltechmail/" owner: "{{ podman_subuid.stdout | int + 32 }}" group: "{{ podman_subuid.stdout | int + 32 }}" mode: 0644 directory_mode: 0755 register: debyltechmail_copy notify: restorecon podman - name: enable debyltechmail app in debyltech-cloud become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ app:enable debyltechmail register: debyltechmail_enable changed_when: "'already enabled' not in debyltechmail_enable.stdout" # System config, set only when it differs so a clean re-deploy reports no # changes (Skudak's equivalents report changed on every run). Values are # single-quoted into the shell, so the backslashes in mail_template_class pass # through literally. overwrite.cli.url is what LibreSign invitation links and # mail asset URLs are built from when sent by a background job. - name: set debyltech-cloud system config become: true become_user: "{{ podman_user }}" ansible.builtin.shell: | set -o pipefail occ() { podman exec -u www-data debyltech-cloud php occ "$@"; } cur=$(occ config:system:get {{ item.k }} || true) if [ "$cur" != {{ item.v | quote }} ]; then occ config:system:set {{ item.k }} --value={{ item.v | quote }} --type={{ item.t | default('string') }} >/dev/null echo CHANGED fi args: executable: /bin/bash register: debyltech_sysconfig changed_when: "'CHANGED' in debyltech_sysconfig.stdout" loop: - {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"} - {k: overwriteprotocol, v: https} - {k: loglevel, v: "2", t: integer} - {k: log_rotate_size, v: "10485760", t: integer} - {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"} - {k: mail_smtpmode, v: smtp} - {k: mail_smtphost, v: "{{ cloud_debyltech_smtp_host }}"} - {k: mail_smtpport, v: "{{ cloud_debyltech_smtp_port }}", t: integer} - {k: mail_smtpsecure, v: ssl} - {k: mail_smtpauth, v: "true", t: boolean} - {k: mail_from_address, v: noreply} - {k: mail_domain, v: debyltech.com} loop_control: label: "{{ item.k }}" - name: set debyltech-cloud SES SMTP credentials become: true become_user: "{{ podman_user }}" ansible.builtin.shell: | set -o pipefail occ() { podman exec -u www-data debyltech-cloud php occ "$@"; } cur=$(occ config:system:get {{ item.k }} || true) if [ "$cur" != {{ item.v | quote }} ]; then occ config:system:set {{ item.k }} --value={{ item.v | quote }} >/dev/null echo CHANGED fi args: executable: /bin/bash register: debyltech_smtp_creds changed_when: "'CHANGED' in debyltech_smtp_creds.stdout" loop: - {k: mail_smtpname, v: "{{ cloud_debyltech_smtp_user }}"} - {k: mail_smtppassword, v: "{{ cloud_debyltech_smtp_pass }}"} loop_control: label: "{{ item.k }}" no_log: true # Compared first: theming:config prints "Updated" even when nothing changed. - name: set debyltech-cloud theming become: true become_user: "{{ podman_user }}" ansible.builtin.shell: | set -o pipefail occ() { podman exec -u www-data debyltech-cloud php occ "$@"; } cur=$(occ config:app:get theming {{ item.k }} || true) if [ "$cur" != {{ item.v | quote }} ]; then occ theming:config {{ item.k }} {{ item.v | quote }} >/dev/null echo CHANGED fi args: executable: /bin/bash loop: - {k: name, v: "de Byl Technologies"} - {k: slogan, v: "Hardware, firmware and design services"} - {k: url, v: "https://debyltech.com"} - {k: primary_color, v: "{{ theming_debyltech_primary }}"} - {k: background_color, v: "{{ theming_debyltech_background }}"} register: debyltech_theming changed_when: "'CHANGED' in debyltech_theming.stdout" loop_control: label: "{{ item.k }}" # The web UI logos ship inside the debyltechmail app (the white variants; the # ink wordmark is the mail one). theming:config re-imports the file on every # call, so it runs only when the app's files changed or no logo is set yet. # `logo` is the wide wordmark on the login page; `logoheader` is the square # mark in the top bar, where a wordmark would shrink to illegibility. - name: check debyltech-cloud theming logos become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ config:app:get theming {{ item }}Mime loop: [logo, logoheader] register: debyltech_logo_mime changed_when: false failed_when: false - name: set debyltech-cloud theming logos become: true become_user: "{{ podman_user }}" ansible.builtin.command: > podman exec -u www-data debyltech-cloud php occ theming:config {{ item.item }} /var/www/html/custom_apps/debyltechmail/img/{{ logo_files[item.item] }} loop: "{{ debyltech_logo_mime.results }}" when: debyltechmail_copy is changed or item.rc != 0 or item.stdout == '' vars: logo_files: logo: debyltech-wordmark-white.png logoheader: debyltech-mark-white.png loop_control: label: "{{ item.item }}" # Fails the play if branding, the LibreSign settings above, or Redis locking # have silently regressed -- see skudak/cloud.yml. - name: template debyltechmail verification script become: true ansible.builtin.template: src: nextcloud/debyltechmail-verify.php.j2 dest: "{{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php" owner: "{{ podman_subuid.stdout }}" group: "{{ podman_subuid.stdout }}" mode: 0644 notify: restorecon podman - name: verify debyltech mail branding is live become: true become_user: "{{ podman_user }}" ansible.builtin.shell: > set -o pipefail; podman exec -i -u www-data debyltech-cloud php < {{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php args: executable: /bin/bash register: debyltechmail_verify changed_when: false - include_tasks: containers/cloud-cron.yml vars: cron_name: debyltech-cloud cron_container: debyltech-cloud cron_script_path: /usr/local/bin/debyltech-cloud-cron.sh # BUSINESS data that DELIBERATELY reaches personal storage -- the opposite of # Skudak, and on purpose: de Byl Technologies LLC is the owner's own company. # # Chain: this rsync -> TrueNAS /mnt/glacier/debyltechcloud (05:00 ZFS # snapshot) -> the personal "iDrive E2 Backup" cloud-sync task, which pushes # /mnt/glacier to the personal iDrive e2 bucket. Unlike /skudakcloud/**, # /skudakapps/** and /skudakgit/**, there is NO exclude for /debyltechcloud/** # on that task, and there must not be one -- that inclusion IS the offsite # copy. If that ever changes, give it its own cloud-sync task first. - include_tasks: containers/cloud-backup.yml vars: backup_name: debyltech-cloud data_path: "{{ cloud_debyltech_path }}/data" config_path: "{{ cloud_debyltech_path }}/config" db_container: debyltech-cloud-db ssh_key_path: /etc/ssh/backup_keys/debyltech-cloud ssh_key_content: "{{ cloud_debyltech_backup_ssh_key }}" ssh_user: debyltechcloud remote_path: /mnt/glacier/debyltechcloud script_path: /usr/local/bin/debyltech-cloud-backup.sh # data/ is mode 770 here too; see skudak/cloud.yml. backup_rsync_extra_args: "--chmod=Du=rwx,Dgo=rx" # Between the 04:00 personal and 04:30 Skudak runs, before the 05:00 # TrueNAS snapshot. backup_oncalendar: "*-*-* 04:15:00"