Last active 3 months ago

As shown on https://youtu.be/P-Pr7Dy6mP8

Revision 65b1fa26d7b15f11ec0b745bd8d5e0ac5ad917bf

README Raw
1Last change: 2025-09-10 Marcel Herrguth
2As shown on https://youtu.be/P-Pr7Dy6mP8
3Attribution back to this GIST is required!
4
5Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup
6Only PostGreSQL is supported; No symlinks are supported
7
8Restoration only works on a pre-installed Zammad installation of the same version (or higher).
9During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty.
10
11
12COMMAND REFERENCE
13- backup: creates a new snapshot and backs up database and storage.
14- restore <snapshot>: Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used.
15- list_snapshots: List all available snapshot IDs.
16- verify_health <percentage>: Verify the health of your backup repository. If you omit percentage, 100% will be assumed.
restic_zammad.sh Raw
1#!/usr/bin/env bash
2#
3# 2025-09-06 Marcel Herrguth
4# As shown on https://youtu.be/P-Pr7Dy6mP8
5# Attribution back to this GIST is required!
6#
7# Parts of this script are oriented from https://github.com/zammad/zammad/tree/develop/contrib/backup
8# Only PostGreSQL is supported; No symlinks are supported
9#
10# Restoration only works on a pre-installed Zammad installation of the same version (or higher).
11# During Restore, this script will remove the existing (local) Zammad database without any warning; if it's an external one, ensure it's empty.
12
13#----- CONFIG -------------------------------------------------------------------------------#
14# Use either a directory or a supported repository type (like s3:https//domain.tld/bucket)
15BACKUP_REPOSITORY='s3:https://s3.domain.tld/resticsample'
16
17# Zammad Installation directory
18ZAMMAD_DIR='/opt/zammad'
19
20# Backup either the whole Zammad directory (yes) or just the storage folder (attachments; no)
21# Storage folder only is best practice as per documentation
22# https://docs.zammad.org/en/latest/appendix/backup-and-restore/index.html
23FULL_FS_DUMP='no'
24
25# How many daily and hourly snapshot should stay available?
26HOLD_DAYS=14
27HOLD_HOURLY=1
28
29# Do you want to cause downtime during backup? (This is the safest backup way)
30STOP_DURING_BACKUP='yes'
31#----- CONFIG END ---------------------------------------------------------------------------#
32
33# ---- Magic --------------------------------------------------------------------------------#
34function pre_flight () {
35 # Verify that we have all we need.
36 if [[ ""$BACKUP_REPOSITORY"" == s3:* ]]; then
37 if [[ -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
38 echo "You have chosen to backup to S3 (it seems), but either AWS_ACCESS_KEY_ID and/or AWS_SECRET_ACCESS_KEY."
39 exit 1
40 fi
41 else
42 echo "This script has been tested with S3 only. This should be fine."
43 echo "Ensure that the required environment information for restic are available as per their documentation:"
44 echo "https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html"
45 fi
46
47 if [[ -z "${RESTIC_PASSWORD}" ]]; then
48 echo "You forgot to set RESTIC_PASSWORD which is mandatory for your backup repository!"
49 exit 1
50 fi
51}
52
53function start_zammad () {
54 echo "# Starting Zammad"
55 systemctl start zammad
56}
57
58function stop_zammad () {
59 echo "# Stopping Zammad"
60 systemctl stop zammad
61}
62
63function get_db_credentials () {
64 DB_HOST="$(grep -m 1 '^[[:space:]]*host:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*host:[[:space:]]*//g')"
65 DB_PORT="$(grep -m 1 '^[[:space:]]*port:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*port:[[:space:]]*//g')"
66 DB_NAME="$(grep -m 1 '^[[:space:]]*database:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*database:[[:space:]]* //g')"
67 DB_USER="$(grep -m 1 '^[[:space:]]*username:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*username:[[:space:]]*//g')"
68 DB_PASS="$(grep -m 1 '^[[:space:]]*password:' < ${ZAMMAD_DIR}/config/database.yml | sed -e 's/.*password:[[:space:]]*//g')"
69
70 # Ensure that HOST and PORT are not empty, provide defaults if needed.
71 if [ "${DB_HOST}x" == "x" ]; then
72 DB_HOST="localhost"
73 fi
74 if [ "${DB_PORT}x" == "x" ]; then
75 DB_PORT="5432"
76 fi
77}
78
79function create_pgpassfile() {
80 PGPASSFILE=$(mktemp)
81 export PGPASSFILE
82 chmod 600 "$PGPASSFILE"
83 cat <<EOT > "$PGPASSFILE"
84*:*:${DB_NAME}:${DB_USER}:${DB_PASS}
85EOT
86 trap 'rm "$PGPASSFILE"' EXIT
87}
88
89function kind_exit () {
90 # We're nice to our admin and bring Zammad back up before exiting
91 start_zammad
92 exit 1
93}
94
95function db_helper_alter_user () {
96 # Get DB credentials
97 get_db_credentials
98
99 if [ "${DB_PASS}x" == "x" ]; then
100 echo "# Found an empty password - I'll be fixing this for you ..."
101
102 DB_PASS="$(tr -dc A-Za-z0-9 < /dev/urandom | head -c10)"
103
104 sed -e "s/.*username:.*/ username: ${DB_USER}/" \
105 -e "s/.*password:.*/ password: ${DB_PASS}/" \
106 -e "s/.*database:.*/ database: ${DB_NAME}/" < ${ZAMMAD_DIR}/contrib/packager.io/database.yml.pkgr > ${ZAMMAD_DIR}/config/database.yml
107
108 echo "# ... Fixing permission database.yml"
109 chown zammad:zammad ${ZAMMAD_DIR}/config/database.yml
110 fi
111
112 if [ "${DB_USER}x" == "x" ]; then
113
114 echo "ERROR - Your configuration file does not seem to contain a username."
115 echo "Aborting the script - double check your installation."
116
117 kind_exit
118 fi
119
120 if [[ ("${DB_HOST}" == "localhost") || "${DB_HOST}" == "127.0.0.1" || "${DB_HOST}" == "::1" ]]; then
121 # Looks like a local pgsql installation - let's continue
122 su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres
123 state=$?
124
125 else
126 echo "You don't seem to be using a local PostgreSQL installation."
127 echo "This script does not support your installation. No changes were done."
128 echo "Please ensure that your database.yml contains a database user password and that the users password fits."
129
130 kind_exit
131 fi
132
133 if [ "${state}" != "0" ]; then
134 echo "ERROR - Our previous command returned an unhandled error code."
135 kind_exit
136 fi
137}
138
139function check_for_empty_password() {
140 if [ "${DB_PASS}x" == "x" ]; then
141 echo "# ERROR - Found an empty database password ..."
142 db_helper_alter_user
143 exit 2
144 fi
145}
146
147function check_database_config_exists () {
148 if [ -f ${ZAMMAD_DIR}/config/database.yml ]; then
149 get_db_credentials
150 else
151 echo -e "${ZAMMAD_DIR}/config/database.yml is missing. is Zammad installed and configured yet? \nAborting..."
152 exit 1
153 fi
154}
155
156function delete_old_backups () {
157 echo "# Invoking cleanup as per snapshot rules"
158 restic -r "$BACKUP_REPOSITORY" forget --group-by '' --keep-hourly $HOLD_HOURLY --keep-daily $HOLD_DAYS --prune
159}
160
161function write_backup () {
162 stop_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'
163 echo "# Creating postgresql backup..."
164
165 create_pgpassfile
166
167 pg_dump --dbname "${DB_NAME}" \
168 --username "${DB_USER}" \
169 ${DB_HOST:+--host $DB_HOST} \
170 ${DB_PORT:+--port $DB_PORT} \
171 --no-privileges --no-owner > /tmp/zammad_db.psql
172
173 state=$?
174
175 if [ "${state}" == "1" ]; then
176 echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
177 echo -e " #-> BACKUP WAS NOT SUCCESSFUL"
178 exit 2
179 fi
180
181 if [ "${FULL_FS_DUMP}" == 'no' ]; then
182 BACKUP_DIR="${ZAMMAD_DIR}/storage/"
183
184 if [ ! -d "${ZAMMAD_DIR}/storage/" ]; then
185 # Admin has requested an attachment backup only, however, there is no storage
186 # directory. We'll warn Mr.Admin and create the directory as workaround.
187 echo " ... WARNING: You don't seem to have any attachments in the file system!"
188 echo " ... This is FINE if you store your attachments either in database or S3!"
189 echo " ... Creating empty storage directory so the backup can continue ..."
190
191 mkdir -p ${ZAMMAD_DIR}/storage/
192 chown -R zammad:zammad ${ZAMMAD_DIR}/storage/
193 fi
194 else
195 BACKUP_DIR="${ZAMMAD_DIR}"
196 fi
197
198 restic -r "$BACKUP_REPOSITORY" migrate
199 restic --no-scan --read-concurrency=25 --pack-size=128 --compression off -r "$BACKUP_REPOSITORY" backup \
200 --exclude "${BACKUP_DIR}/tmp/*" --exclude "${BACKUP_DIR}/node_modules/" \
201 --exclude "${BACKUP_DIR}/.git/" --exclude "${BACKUP_DIR}/.yarn/cache/" \
202 ${BACKUP_DIR} /tmp/zammad_db.psql
203
204 state=$?
205
206 # clean up temporary database dump
207 rm -f /tmp/zammad_db.psql
208
209 start_zammad if "${STOP_DURING_BACKUP}x" == 'yesx'
210
211 if [ $state == '1' ]; then
212 echo "# FATAL - Restic could not create the snapshot."
213 elif [ $state == '3' ]; then
214 echo "# WARNING - Files changed during snapshot creation; Snapshot potentially incomplete!"
215 fi
216
217 if [ $state -gt 0 ]; then
218 echo "# BACKUP WAS NOT SUCCESSFUL"
219 exit $state
220 fi
221}
222
223function list_available_snapshots () {
224 pre_flight
225 echo "# Here's your currently available snapshots in your backup repository:"
226 restic -r "$BACKUP_REPOSITORY" snapshots
227}
228
229function verify_backup_repository_health () {
230 pre_flight
231
232 VERIFY_PERCENTAGE="${1:-100}"
233
234 echo "# Checking ${VERIFY_PERCENTAGE} of your repository ..."
235 restic -r "$BACKUP_REPOSITORY" check --read-data-subset="${VERIFY_PERCENTAGE}%"
236}
237
238function get_snapshot_id () {
239 if [ -n "${1}" ]; then
240 RESTORE_SNAPSHOT_ID="${1}"
241 else
242 # User did not provide snapshot so we'll hard guess 'latest'
243 RESTORE_SNAPSHOT_ID='latest'
244
245 echo "... No Snapshot provided, guessing you want to restore 'latest'!"
246 for i in {15..1}; do
247 echo -ne "... You have $i seconds to abort.\r"; sleep 1
248 done
249 fi
250}
251
252function restore_backup () {
253 echo "# Checking requirements"
254
255 if ! id "zammad" &> /dev/null; then
256 echo "# ERROR: User 'zammad' is not present, but should be by default! #"
257 echo "# Restoration was NOT successful, exiting ..."
258 exit 1
259 fi
260
261 stop_zammad
262
263 echo "# ... Dropping current database ${DB_NAME}"
264
265 # This step is only needed for the majority of pgsql dumps, as they don't provide a drop table statement which will cause
266 # relation errors during restoration (as on package installation there's always an initialized database)
267 if command -v zammad > /dev/null; then
268 zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=1
269 zammad run rake db:drop
270 zammad config:set DISABLE_DATABASE_ENVIRONMENT_CHECK=0
271 else
272 DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ${ZAMMAD_DIR}/bin/rake db:drop
273 fi
274
275 echo "# ... Creating database ${DB_NAME} for owner ${DB_USER}"
276 if command -v zammad > /dev/null; then
277 # We'll skip this part for docker installations
278 su -c "psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres
279 else
280 ${ZAMMAD_DIR}/bin/rake db:create
281 fi
282
283 echo "# Restoring PostgreSQL DB"
284
285 create_pgpassfile
286
287 # We're removing uncritical dump information that caused "ugly" error
288 # messages on older script versions. These could safely be ignored.
289 restic -r "$BACKUP_REPOSITORY" dump latest '/tmp/zammad_db.psql' | \
290 sed '/^CREATE EXTENSION IF NOT EXISTS plpgsql/d'| \
291 sed '/^COMMENT ON EXTENSION plpgsql/d'| \
292 psql -q -b -o /dev/null \
293 ${DB_HOST:+--host $DB_HOST} ${DB_PORT:+--port $DB_PORT} ${DB_USER:+--username $DB_USER} --dbname ${DB_NAME}
294
295 state=$?
296
297 if [[ ("${state}" == "1") || ( "${state}" == "2") || ( "${state}" == "3") ]]; then
298 # We're checking for critical restoration errors
299 # It may not cover all possible errors which is out of scope of this script
300 echo -e "\n\n # ERROR(${state}) - Database credentials are wrong or database server configuration is invalid."
301 echo -e " #-> RESTORE WAS NOT SUCCESSFUL"
302 exit 2
303 fi
304
305 if [ "${FULL_FS_DUMP}" == 'no' ]; then
306 BACKUP_DIR="${ZAMMAD_DIR}/storage/"
307 else
308 BACKUP_DIR="${ZAMMAD_DIR}"
309 fi
310
311 echo "# Restoring Files"
312 restic -r "$BACKUP_REPOSITORY" restore $RESTORE_SNAPSHOT_ID --include "$BACKUP_DIR" --target /
313
314 state=$?
315
316 if [[ ($state == '1') || ($state == '2') ]]; then
317 echo "# ERROR(${state}) - File restore reported an error."
318 echo "- Check file permissions, and ensure Zammad IS NOT running, and try again."
319 echo -e " \n# RESTORE WAS NOT SUCCESSFUL"
320 exit 1
321 fi
322
323 echo "# Ensuring correct file permissions ..."
324 chown -R zammad:zammad ${ZAMMAD_DIR}
325
326 # Ensure all data is loaded from the restored database and not the cache of the previous system state
327 echo "# Clearing Cache ..."
328 if command -v zammad > /dev/null; then
329 zammad run rails r "Rails.cache.clear"
330 else
331 ${ZAMMAD_DIR}/bin/rails r "Rails.cache.clear"
332 fi
333
334 echo "# Attempting to execute database migrations"
335 if command -v zammad > /dev/null; then
336 zammad run rake db:migrate
337 else
338 ${ZAMMAD_DIR}/bin/rake db:migrate
339 fi
340
341 start_zammad
342}
343
344function start_backup_message () {
345 echo -e "\n# Backup script started - $(date)!\n"
346}
347
348function start_restore_message () {
349 echo -e "\n# Restore script started - $(date)!\n"
350}
351
352function finished_backup_message () {
353 echo -e "\n# Backup script finished; Check output! - $(date)!\n"
354}
355
356function finished_restore_message () {
357 echo -e "\n# Restore script finished; Check output! - $(date)!\n"
358}
359
360function execute_backup () {
361 pre_flight
362 start_backup_message
363 check_database_config_exists
364 check_for_empty_password
365 write_backup
366 delete_old_backups
367 finished_backup_message
368}
369
370function execute_restoration () {
371 pre_flight
372 check_database_config_exists
373 check_for_empty_password
374 start_restore_message
375 get_snapshot_id $1
376 restore_backup
377 finished_restore_message
378}
379
380function command_reference () {
381 echo "COMMAND REFERENCE"
382 echo "- backup: creates a new snapshot and backs up database and storage."
383 echo "- restore <snapshot>: Restore a given snapshot from your backup. If you omit the snapshot ID, latest will be used."
384 echo "- list_snapshots: List all available snapshot IDs."
385 echo "- verify_health <percentage>: Verify the health of your backup repository. If you omit percentage, 100% will be assumed."
386}
387
388# ---- Option part and control --------------------------------------------------------------#
389
390case "$1" in
391 backup)
392 execute_backup
393 ;;
394
395 restore)
396 execute_restoration $2
397 ;;
398
399 list_snapshots)
400 list_available_snapshots
401 ;;
402
403 verify_health)
404 verify_backup_repository_health "$2"
405 ;;
406
407 *)
408 command_reference
409 ;;
410esac