#!/usr/bin/env bash # # 2025-09-06 Marcel Herrguth # As shown on https://youtu.be/P-Pr7Dy6mP8 # Attribution back to this GIST is required! # # Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup # Only PostGreSQL is supported; No symlinks are supported # # Restoration only works on a pre-installed Zammad installation of the same version (or higher). # During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty. #----- CONFIG -------------------------------------------------------------------------------# # Use either a directory or a supported repository type (like s3:https//domain.tld/bucket) BACKUP_REPOSITORY='s3:https://s3.domain.tld/resticsample' # Zammad Installation directory ZAMMAD_DIR='/opt/zammad' # Backup either the whole Zammad directory (yes) or just the storage folder (attachments; no) # Storage folder only is best practice as per documentation # https://docs.zammad.org/en/latest/appendix/backup-and-restore/index.html FULL_FS_DUMP='no' # How many daily and hourly snapshot should stay available? HOLD_DAYS=14 HOLD_HOURLY=1 # Do you want to cause downtime during backup? (This is the safest backup way) STOP_DURING_BACKUP='yes' #----- CONFIG END ---------------------------------------------------------------------------# # ---- Magic --------------------------------------------------------------------------------# function pre_flight () { # Verify that we have all we need. if [[ ""$BACKUP_REPOSITORY"" == s3:* ]]; then if [[ -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then echo "You have chosen to backup to S3 (it seems), but either AWS_ACCESS_KEY_ID and/or AWS_SECRET_ACCESS_KEY." exit 1 fi else echo "This script has been tested with S3 only. This should be fine." echo "Ensure that the required environment information for restic are available as per their documentation:" echo "https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html" fi if [[ -z "${RESTIC_PASSWORD}" ]]; then echo "You forgot to set RESTIC_PASSWORD which is mandatory for your backup repository!" exit 1 fi } function start_zammad () { echo "# Starting Zammad" systemctl start zammad } function stop_zammad () { echo "# Stopping Zammad" systemctl stop zammad } function get_db_credentials () { DB_HOST="$(grep -m 1 '^[[:space:]]*host:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*host:[[:space:]]*//g')" DB_PORT="$(grep -m 1 '^[[:space:]]*port:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*port:[[:space:]]*//g')" DB_NAME="$(grep -m 1 '^[[:space:]]*database:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*database:[[:space:]]* //g')" DB_USER="$(grep -m 1 '^[[:space:]]*username:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*username:[[:space:]]*//g')" DB_PASS="$(grep -m 1 '^[[:space:]]*password:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*password:[[:space:]]*//g')" # Ensure that HOST and PORT are not empty, provide defaults if needed. if [ "${DB_HOST}x" == "x" ]; then DB_HOST="localhost" fi if [ "${DB_PORT}x" == "x" ]; then DB_PORT="5432" fi } function create_pgpassfile() { PGPASSFILE=$(mktemp) export PGPASSFILE chmod 600 "$PGPASSFILE" cat < "$PGPASSFILE" *:*:${DB_NAME}:${DB_USER}:${DB_PASS} EOT trap 'rm "$PGPASSFILE"' EXIT } function kind_exit () { # We're nice to our admin and bring Zammad back up before exiting start_zammad exit 1 } function db_helper_alter_user () { # Get DB credentials get_db_credentials if [ "${DB_PASS}x" == "x" ]; then echo "# Found an empty password - I'll be fixing this for you ..." DB_PASS="$(tr -dc A-Za-z0-9 < /dev/urandom | head -c10)" sed -e "s/.*username:.*/ username: ${DB_USER}/" \ -e "s/.*password:.*/ password: ${DB_PASS}/" \ -e "s/.*database:.*/ database: ${DB_NAME}/" < ${ZAMMAD_DIR}/contrib/packager.io/database.yml.pkgr > ${ZAMMAD_DIR}/config/database.yml echo "# ... Fixing permission database.yml" chown zammad:zammad ${ZAMMAD_DIR}/config/database.yml fi if [ "${DB_USER}x" == "x" ]; then echo "ERROR - Your configuration file does not seem to contain a username." echo "Aborting the script - double check your installation." kind_exit fi if [[ ("${DB_HOST}" == "localhost") || "${DB_HOST}" == "127.0.0.1" || "${DB_HOST}" == "::1" ]]; then # Looks like a local pgsql installation - let's continue su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres state=$? else echo "You don't seem to be using a local PostgreSQL installation." echo "This script does not support your installation. No changes were done." echo "Please ensure that your database.yml contains a database user password and that the users password fits." kind_exit fi if [ "${state}" != "0" ]; then echo "ERROR - Our previous command returned an unhandled error code." kind_exit fi } function check_for_empty_password() { if [ "${DB_PASS}x" == "x" ]; then echo "# ERROR - Found an empty database password ..." db_helper_alter_user exit 2 fi } function check_database_config_exists () { if [ -f ${ZAMMAD_DIR}/config/database.yml ]; then get_db_credentials else echo -e "${ZAMMAD_DIR}/config/database.yml is missing. is Zammad installed and configured yet? \nAborting..." exit 1 fi } function delete_old_backups () { echo "# Invoking cleanup as per snapshot rules" restic -r "$BACKUP_REPOSITORY" forget --group-by '' --keep-hourly $HOLD_HOURLY --keep-daily $HOLD_DAYS --prune } function write_backup () { stop_zammad if "${STOP_DURING_BACKUP}x" == 'yesx' echo "# Creating postgresql backup..." create_pgpassfile pg_dump --dbname "${DB_NAME}" \ --username "${DB_USER}" \ ${DB_HOST:+--host $DB_HOST} \ ${DB_PORT:+--port $DB_PORT} \ --no-privileges --no-owner > /tmp/zammad_db.psql state=$? if [ "${state}" == "1" ]; then echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid." echo -e " #-> BACKUP WAS NOT SUCCESSFUL" exit 2 fi if [ "${FULL_FS_DUMP}" == 'no' ]; then BACKUP_DIR="${ZAMMAD_DIR}/storage/" if [ ! -d "${ZAMMAD_DIR}/storage/" ]; then # Admin has requested an attachment backup only, however, there is no storage # directory. We'll warn Mr.Admin and create the directory as workaround. echo " ... WARNING: You don't seem to have any attachments in the file system!" echo " ... This is FINE if you store your attachments either in database or S3!" echo " ... Creating empty storage directory so the backup can continue ..." mkdir -p ${ZAMMAD_DIR}/storage/ chown -R zammad:zammad ${ZAMMAD_DIR}/storage/ fi else BACKUP_DIR="${ZAMMAD_DIR}" fi restic -r "$BACKUP_REPOSITORY" migrate restic --no-scan --read-concurrency=25 --pack-size=128 --compression off -r "$BACKUP_REPOSITORY" backup \ --exclude "${BACKUP_DIR}/tmp/*" --exclude "${BACKUP_DIR}/node_modules/" \ --exclude "${BACKUP_DIR}/.git/" --exclude "${BACKUP_DIR}/.yarn/cache/" \ ${BACKUP_DIR} /tmp/zammad_db.psql state=$? # clean up temporary database dump rm -f /tmp/zammad_db.psql start_zammad if "${STOP_DURING_BACKUP}x" == 'yesx' if [ $state == '1' ]; then echo "# FATAL - Restic could not create the snapshot." elif [ $state == '3' ]; then echo "# WARNING - Files changed during snapshot creation; Snapshot potentially incomplete!" fi if [ $state -gt 0 ]; then echo "# BACKUP WAS NOT SUCCESSFUL" exit $state fi } function list_available_snapshots () { pre_flight echo "# Here's your currently available snapshots in your backup repository:" restic -r "$BACKUP_REPOSITORY" snapshots } function verify_backup_repository_health () { pre_flight VERIFY_PERCENTAGE="${1:-100}" echo "# Checking ${VERIFY_PERCENTAGE} of your repository ..." restic -r "$BACKUP_REPOSITORY" check --read-data-subset="${VERIFY_PERCENTAGE}%" } function get_snapshot_id () { if [ -n "${1}" ]; then RESTORE_SNAPSHOT_ID="${1}" else # User did not provide snapshot so we'll hard guess 'latest' RESTORE_SNAPSHOT_ID='latest' echo "... No Snapshot provided, guessing you want to restore 'latest'!" for i in {15..1}; do echo -ne "... You have $i seconds to abort.\r"; sleep 1 done fi } function restore_backup () { echo "# Checking requirements" if ! id "zammad" &> /dev/null; then echo "# ERROR: User 'zammad' is not present, but should be by default! #" echo "# Restoration was NOT successful, exiting ..." exit 1 fi stop_zammad echo "# ... Dropping current database ${DB_NAME}" # This step is only needed for the majority of pgsql dumps, as they don't provide a drop table statement which will cause # relation errors during restoration (as on package installation there's always an initialized database) if command -v zammad > /dev/null; then zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=1 zammad run rake db:drop zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=0 else DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ${ZAMMAD_DIR}/bin/rake db:drop fi echo "# ... Creating database ${DB_NAME} for owner ${DB_USER}" if command -v zammad > /dev/null; then # We'll skip this part for docker installations su -c "psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres else ${ZAMMAD_DIR}/bin/rake db:create fi echo "# Restoring PostgreSQL DB" create_pgpassfile # We're removing uncritical dump information that caused "ugly" error # messages on older script versions. These could safely be ignored. restic -r "$BACKUP_REPOSITORY" dump latest '/tmp/zammad_db.psql' | \ sed '/^CREATE EXTENSION IF NOT EXISTS plpgsql/d'| \ sed '/^COMMENT ON EXTENSION plpgsql/d'| \ psql -q -b -o /dev/null \ ${DB_HOST:+--host $DB_HOST} ${DB_PORT:+--port $DB_PORT} ${DB_USER:+--username $DB_USER} --dbname ${DB_NAME} state=$? if [[ ("${state}" == "1") || ( "${state}" == "2") || ( "${state}" == "3") ]]; then # We're checking for critical restoration errors # It may not cover all possible errors which is out of scope of this script echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid." echo -e " #-> RESTORE WAS NOT SUCCESSFUL" exit 2 fi if [ "${FULL_FS_DUMP}" == 'no' ]; then BACKUP_DIR="${ZAMMAD_DIR}/storage/" else BACKUP_DIR="${ZAMMAD_DIR}" fi echo "# Restoring Files" restic -r "$BACKUP_REPOSITORY" restore $RESTORE_SNAPSHOT_ID --include "$BACKUP_DIR" --target / state=$? if [[ ($state == '1') || ($state == '2') ]]; then echo "# ERROR(${state}) - File restore reported an error." echo "- Check file permissions, and ensure Zammad IS NOT running, and try again." echo -e " \n# RESTORE WAS NOT SUCCESSFUL" exit 1 fi echo "# Ensuring correct file permissions ..." chown -R zammad:zammad ${ZAMMAD_DIR} # Ensure all data is loaded from the restored database and not the cache of the previous system state echo "# Clearing Cache ..." if command -v zammad > /dev/null; then zammad run rails r "Rails.cache.clear" else ${ZAMMAD_DIR}/bin/rails r "Rails.cache.clear" fi echo "# Attempting to execute database migrations" if command -v zammad > /dev/null; then zammad run rake db:migrate else ${ZAMMAD_DIR}/bin/rake db:migrate fi start_zammad } function start_backup_message () { echo -e "\n# Backup script started - $(date)!\n" } function start_restore_message () { echo -e "\n# Restore script started - $(date)!\n" } function finished_backup_message () { echo -e "\n# Backup script finished; Check output! - $(date)!\n" } function finished_restore_message () { echo -e "\n# Restore script finished; Check output! - $(date)!\n" } function execute_backup () { pre_flight start_backup_message check_database_config_exists check_for_empty_password write_backup delete_old_backups finished_backup_message } function execute_restoration () { pre_flight check_database_config_exists check_for_empty_password start_restore_message get_snapshot_id $1 restore_backup finished_restore_message } function command_reference () { echo "COMMAND REFERENCE" echo "- backup: creates a new snapshot and backs up database and storage." echo "- restore : Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used." echo "- list_snapshots: List all available snapshot IDs." echo "- verify_health : Verify the health of your backup repository. If you omit percentage, 100% will be assumed." } # ---- Option part and control --------------------------------------------------------------# case "$1" in backup) execute_backup ;; restore) execute_restoration $2 ;; list_snapshots) list_available_snapshots ;; verify_health) verify_backup_repository_health "$2" ;; *) command_reference ;; esac