├── .gitattributes ├── assets ├── backupdate.png └── backupdate.webp ├── cliff.toml ├── CHANGELOG.md ├── README.md ├── LICENSE └── backupdate.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/backupdate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazzuk/compose-backupdate/HEAD/assets/backupdate.png -------------------------------------------------------------------------------- /assets/backupdate.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazzuk/compose-backupdate/HEAD/assets/backupdate.webp -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | # template set as unconventional 5 | # 6 | # recognise pull requests: 7 | # commit_parsers = [ 8 | # { message = "^Merge", group = "Pull Requests" }, 9 | 10 | 11 | # github release steps 12 | 13 | # 1. add version number to code 14 | # 2. `git cliff --tag 1.2.3 -o CHANGELOG.md` 15 | # 3. `chore(release): prepare for v1.2.3` 16 | 17 | # githubrelease header 18 | 19 | # REMOVE 20 | # All notable changes to this project will be documented in this file. 21 | # 22 | # ADD 23 | # Note on changes 24 | # 25 | # AND 26 | # #### Install command 27 | # ```bash 28 | # bash -c 'curl -fsSL -o /bin/backupdate https://raw.githubusercontent.com/hazzuk/compose-backupdate/refs/heads/release/backupdate.sh && chmod +x /bin/backupdate' 29 | # ``` 30 | 31 | 32 | # [remote.github] 33 | # owner = "hazzuk" 34 | # repo = "compose-backupdate" 35 | 36 | [changelog] 37 | # template for the changelog header 38 | header = """ 39 | # Changelog\n 40 | All notable changes to this project will be documented in this file.\n 41 | """ 42 | # template for the changelog body 43 | # https://keats.github.io/tera/docs/#introduction 44 | body = """ 45 | {% if version %}\ 46 | ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} 47 | {% else %}\ 48 | ## Unreleased 49 | {% endif %}\ 50 | {% for group, commits in commits | group_by(attribute="group") %} 51 | ### {{ group | upper_first }} 52 | {% for commit in commits %} 53 | - {{ commit.message | split(pat="\n") | first | trim_end }}\ 54 | {% endfor %} 55 | {% endfor %}\n 56 | """ 57 | # template for the changelog footer 58 | footer = """ 59 | 60 | """ 61 | # remove the leading and trailing whitespace from the templates 62 | trim = true 63 | 64 | 65 | [git] 66 | # parse the commits based on https://www.conventionalcommits.org 67 | conventional_commits = false 68 | # filter out the commits that are not conventional 69 | filter_unconventional = false 70 | # process each line of a commit as an individual commit 71 | split_commits = false 72 | # regex for parsing and grouping commits 73 | commit_parsers = [ 74 | { message = "^Merge", group = "Pull Requests" }, 75 | { message = "^feat", group = "Features" }, 76 | { message = "^fix", group = "Bug Fixes" }, 77 | { message = "^doc", group = "Documentation" }, 78 | { message = "^perf", group = "Performance" }, 79 | { message = "^refactor", group = "Refactor" }, 80 | { message = "^style", group = "Styling" }, 81 | { message = "^test", group = "Testing" }, 82 | { message = "^chore\\(release\\): prepare for", skip = true }, 83 | { message = "^chore", group = "Miscellaneous Tasks" }, 84 | { body = ".*security", group = "Security" }, 85 | { body = ".*", group = "Other (unconventional)" }, 86 | ] 87 | # filter out the commits that are not matched by commit parsers 88 | filter_commits = false 89 | # sort the tags topologically 90 | topo_order = false 91 | # sort the commits inside sections by oldest/newest order 92 | sort_commits = "oldest" 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 1.2.0 - 2024-12-04 6 | 7 | ### Bug Fixes 8 | 9 | - fix(prune): only prune running stacks 10 | - fix: docker stop command wrong 11 | 12 | ### Documentation 13 | 14 | - docs: create faq section 15 | - docs: update faq restore answer 16 | - docs: update arguments phrasing 17 | - docs(logo): add new logo 18 | - docs: update project name 19 | - docs: updates to the readme 20 | 21 | ### Features 22 | 23 | - feat(restart): only restart running containers 24 | - feat(docker stop): improve error handling 25 | - feat(updates): remove/recreate all containers 26 | 27 | ### Miscellaneous Tasks 28 | 29 | - chore: update copyright notice 30 | - chore(logo): remove previous version 31 | 32 | ### Pull Requests 33 | 34 | - Merge pull request #17 from hazzuk/update-prune 35 | - Merge pull request #18 from hazzuk/fix-docker-stop 36 | - Merge pull request #19 from hazzuk/track-container-states 37 | 38 | ### Refactor 39 | 40 | - refactor: script update function name 41 | 42 | ### Styling 43 | 44 | - style: script console outputs 45 | 46 | ## 1.1.0 - 2024-10-07 47 | 48 | ### Bug Fixes 49 | 50 | - fix: wrong project about line 51 | 52 | ### Documentation 53 | 54 | - docs: reorganise content placement 55 | - docs: simplify functionality guide 56 | - docs: adjust updates wording 57 | - docs: add backup_blocklist guide 58 | - docs: update blocklist guide wording 59 | 60 | ### Features 61 | 62 | - feat: create volume blocklist option 63 | - feat: skip blocklisted volumes at backup 64 | - feat: create path blocklist 65 | - feat: skip blocklisted paths at backup 66 | 67 | ### Miscellaneous Tasks 68 | 69 | - chore: switch to release branch 70 | - chore: create project icon 71 | - chore: move project header image 72 | - chore: create full size project icon 73 | - chore: add comments for each section 74 | 75 | ### Pull Requests 76 | 77 | - Merge pull request #9 from hazzuk/backup-blocklist 78 | 79 | ### Styling 80 | 81 | - style: remove excess whitespace 82 | 83 | ## 1.0.0 - 2024-10-03 84 | 85 | ### Bug Fixes 86 | 87 | - fix: script variables not set 88 | - fix: working_dir value overwritten 89 | - fix: update stack_name when compose found 90 | - fix: docker_dir validation method 91 | - fix: update option requiring an argument 92 | - fix: more blank line outputs 93 | - fix: no such directory error 94 | - fix: backup volume wording 95 | - fix: version checker formatting 96 | - fix: unintended busybox prune 97 | - fix: avoid using subshell 98 | - fix(prune): premature script exit 99 | - fix(start): wording and echo placement 100 | - fix(prune): echo not formatted 101 | - fix: remove incomplete changelog feature 102 | 103 | ### Documentation 104 | 105 | - docs: script setup and usage 106 | - docs: add project header and args usage 107 | - docs: explained working_dir 108 | - docs: add update option and current dir 109 | - docs: add expanded options 110 | - docs: update repo description 111 | - docs: expand on script examples 112 | - docs: add reasons behind scripts origin 113 | - docs: minor word/style changes 114 | - docs: explain core functionality 115 | - docs: minor visual changes 116 | 117 | ### Features 118 | 119 | - feat: automatic volume backups 120 | - feat: add script usage, argument options 121 | - feat: add command to update compose stack 122 | - feat: add config validation 123 | - feat: refine console output 124 | - feat: more console output refinements 125 | - feat: normalise console outputs 126 | - feat: echo blank line breaks 127 | - feat: backup into stack_name directories 128 | - feat: add input validation before compose search 129 | - feat: add version update checker 130 | - feat: make version check optional 131 | - feat: add long options 132 | - feat: prune unused images after update 133 | 134 | ### Miscellaneous Tasks 135 | 136 | - chore: initial commit 137 | - chore: create git-cliff config 138 | 139 | ### Other (unconventional) 140 | 141 | - merge: #1 from hazzuk/long-options 142 | 143 | ### Pull Requests 144 | 145 | - Merge pull request #2 from hazzuk/image-prune 146 | 147 | ### Refactor 148 | 149 | - refactor: confirmation prompts 150 | 151 | ### Styling 152 | 153 | - style(prune): add hyphen for subcommand echo 154 | - style(changelog): remove leading echo 155 | 156 | ### Testing 157 | 158 | - test: bump version number 159 | 160 | 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # backupdate 6 | 7 | Bash script for creating scheduled backups, and performing (backed-up) guided updates on Docker compose stacks. 8 | 9 |
10 | 11 | ## Why? 12 | Because I needed a tool that was... 13 | 14 | - Simple by design 15 | - Doesn't require changes inside my `compose.yaml` files 16 | - Works with both **bind mounts** and **named volumes** 17 | - Can be used to create 🕑scheduled backups 18 | - Can also create ad-hoc backups alongside guided container ⬆️updates 19 | - Not trying to replace existing cloud backup tools (like rclone) 20 | 21 |
22 | 23 | ## Core Functionality 24 | 25 | The core focus of *backupdate* is in creating archived backups of your Docker compose stacks. 26 | 27 | ### How it works 28 | 29 | 1. 🛑Stop any running containers in the Docker compose stack 30 | 1. 📁Create a **.tar.gz** backup of the stacks working directory 31 | 1. 📁Create **.tar.gz** backups of any associated named volumes 32 | 1. ⬇️Ask to pull any new container images (`-u`) 33 | 1. 🔁Restart previously running Docker compose containers 34 | 1. 🗑️Ask to prune any unused container images (`-u`) 35 | 36 | Read the official Docker documentation for more details on ["Back up, restore, or migrate data volumes"](https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes). 37 | 38 |
39 | 40 | ## Setup 41 | 42 | ### Install 43 | > [!WARNING] 44 | > This script is provided as-is, without any warranty. Use it at your own risk. 45 | 46 | > [!IMPORTANT] 47 | > The install command and the script must be run with elevated permissions. 48 | 49 | ```bash 50 | bash -c 'curl -fsSL -o /bin/backupdate https://raw.githubusercontent.com/hazzuk/compose-backupdate/refs/heads/release/backupdate.sh && chmod +x /bin/backupdate' 51 | ``` 52 | 53 | ### Expected compose directory structure 54 | The script expects your docker compose working directory to be located at `$docker_dir/$stack_name`: 55 | ``` 56 | $docker_dir = "/path/to/your/docker" 57 | $stack_name = "nextcloud" 58 | 59 | docker/ 60 | ├─ nginx/ 61 | │ └─ compose.yaml 62 | ├─ wordpress/ 63 | │ └─ compose.yaml 64 | └─ nextcloud/ 65 | └─ compose.yaml 66 | ``` 67 | 68 |
69 | 70 | ## Options 71 | 72 | ### Command line 73 | 74 | #### Required 75 | - `-b ""`, `--backup-dir ""`: Backup directory 76 | - `-d ""`, `--docker-dir ""`: Docker compose directory parent 77 | - `-s ""`, `--stack-name ""`: Docker compose stack name 78 | 79 | #### Optional 80 | - `-l ""`, `--backup-blocklist ""`: Volumes/paths to ignore 81 | - `-u`, `--update`: Update the stack containers 82 | - `-v`, `--version`: Check the script version for updates 83 | 84 | ### Environment variables 85 | ```bash 86 | # backup directory 87 | export BACKUP_DIR="/path/to/your/backup" 88 | # docker compose directory parent 89 | export DOCKER_DIR="/path/to/your/docker" 90 | # docker compose stack name 91 | export STACK_NAME="nginx" 92 | # volumes/paths to ignore 93 | export BACKUP_BLOCKLIST="plex_media,/plex-cache" 94 | ``` 95 | 96 |
97 | 98 | ## Example Usage 99 | 100 | ### 📀Backups 101 | ```bash 102 | backupdate -s "nginx" -d "/path/to/your/docker" -b "/path/to/your/backup" 103 | ``` 104 | ```bash 105 | backupdate --stack-name "nginx" \ 106 | --docker-dir "/very/long/path/to/docker" \ 107 | --backup-dir "/very/long/path/to/the/backup" 108 | ``` 109 | 110 | > [!TIP] 111 | > *backupdate* automatically searches for a `compose.yaml` / `docker-compose.yaml` file inside your current directory. 112 | > Running *backupdate* inside your Docker compose working directory won't require `--docker-dir` or `--stack-name`: 113 | 114 | ```bash 115 | cd /path/to/your/docker/nginx 116 | 117 | backupdate -u -b "/path/to/your/backup" 118 | ``` 119 | 120 |
121 | 122 | ### ⬆️Updates *(manual only)* 123 | > [!NOTE] 124 | > Stack updates (unlike backups) can only be performed manually. This is by design. 125 | 126 | ```bash 127 | backupdate -u -s "nginx" -d "/path/to/your/docker" -b "/path/to/your/backup" 128 | ``` 129 | 130 |
131 | 132 | ### 🕑Scheduled backups 133 | You can create a cron job or use another tool like [Cronicle](https://github.com/jhuckaby/Cronicle) to run something similar to this example script. Which will periodically backup your Docker compose stacks automatically: 134 | 135 | ```bash 136 | #!/bin/bash 137 | 138 | # set environment variables 139 | export DOCKER_DIR="/path/to/your/docker" 140 | export BACKUP_DIR="/path/to/your/backup" 141 | 142 | # set stack names 143 | stack_names=( 144 | "nginx" 145 | "portainer" 146 | "ghost" 147 | "home-assistant" 148 | ) 149 | 150 | # create backups 151 | for stack in "${stack_names[@]}"; do 152 | backupdate -s "$stack" 153 | done 154 | 155 | # upload backups to cloud storage 156 | rclone sync $BACKUP_DIR dropbox:backup 157 | ``` 158 | 159 |
160 | 161 | ### 🚫Backup blocklist 162 | 163 | By default, *backupdate* will backup all related named volumes and the stacks full working directory. You can use `-l` or `--backup-blocklist` if you want to explicitly exclude certain volumes or paths from the backup. 164 | 165 | ```bash 166 | # ignore the plex_media volume and the /plex-cache directory 167 | 168 | backupdate -s "plex" \ 169 | -d "/path/to/your/docker" \ 170 | -b "/path/to/your/backup" \ 171 | -l "plex_media,/plex-cache" 172 | ``` 173 | 174 | ```bash 175 | # you'll likely want to set the backup blocklist as an environment variable 176 | # when you need to ignore volumes/paths for multiple stacks 177 | 178 | export BACKUP_BLOCKLIST="\ 179 | plex_media,\ 180 | /plex-cache,\ 181 | /nginx.conf,\ 182 | nginx_logs,\ 183 | /data/ghost.yml" 184 | ``` 185 | 186 | > [!TIP] 187 | > To avoid being recognised as a volume, paths must start with a forward slash `/`. 188 | > Note that paths are interpreted as glob(3)-style wildcard patterns. 189 | 190 |
191 | 192 | ## FAQ 193 | 194 | ### How do I restore the backup? 195 | 196 | Restoring backups isn't currently a process automated by the script. But as *backupdate* is just executing [standard Docker commands](https://github.com/hazzuk/compose-backupdate/blob/7b3d2edb05374e707af79c00d303e9988065e7f8/backupdate.sh#L347-L351), just follow the official Docker guide on ["Restoring volumes from backups"](https://docs.docker.com/engine/storage/volumes/#restore-volume-from-a-backup). 197 | 198 | ### Do I need to stop containers before running backupdate? 199 | 200 | No, *backupdate* does that for you. All containers are stopped automatically before a backup to ensure the data saved is reliable. 201 | 202 | ### Are there any differences between backups and updates? 203 | 204 | Yes, *backupdate* is focused on backups. However, it can be used to simultaneously backup and update containers in a Docker compose stack. Running update will remove (`docker compose down`) all containers, perform a standard backup, then request the containers be recreated (`docker compose up -d`). This is different to backups, where the containers are only stopped, backed up, and then restarted. As updating a Docker container requires the container to be recreated so as to use the newly pulled Docker image. 205 | 206 | ### How does it backup DB volumes? 207 | 208 | The script treats all containers and their volumes the same. Shut them down, back them up, then restart them. 209 | 210 | There are alternative tools that do specifically handle database backups with Docker. This would probably be most useful if you don't want any downtime. Or want to create a lot more backups each day, then use another tool with deduplication abilities to save on storage space. 211 | 212 | ### Alternative tools 213 | 214 | - [offen/docker-volume-backup](https://github.com/offen/docker-volume-backup) 215 | - [loomchild/volume-backup](https://github.com/loomchild/volume-backup) 216 | - [tiredofit/docker-db-backup](https://github.com/tiredofit/docker-db-backup) 217 | - [prodrigestivill/docker-postgres-backup-local](https://github.com/prodrigestivill/docker-postgres-backup-local) 218 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /backupdate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | version="1.2.0" 3 | 4 | # Copyright (c) 2024 hazzuk 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 8 | 9 | 10 | # _ _ _ _ 11 | # ___ ___ _____ ___ ___ ___ ___ ___| |_ ___ ___| |_ _ _ ___ _| |___| |_ ___ 12 | # | _| . | | . | . |_ -| -_|___| . | .'| _| '_| | | . | . | .'| _| -_| 13 | # |___|___|_|_|_| _|___|___|___| |___|__,|___|_,_|___| _|___|__,|_| |___| 14 | # |_| |_| 15 | # 16 | # Bash script for creating scheduled backups, and performing (backed-up) guided updates on Docker compose stacks 17 | # https://github.com/hazzuk/compose-backupdate 18 | 19 | 20 | # exit on any error 21 | set -e 22 | 23 | # check if running as root 24 | if [ "$EUID" -ne 0 ]; then 25 | echo "Error, please run as root!" 26 | exit 1 27 | fi 28 | 29 | # variables 30 | # --- 31 | 32 | # required 33 | backup_dir=${BACKUP_DIR:-"null"} # -b "/opt/backup" 34 | docker_dir=${DOCKER_DIR:-"null"} # -d "/opt/docker" 35 | stack_name=${STACK_NAME:-"null"} # -s "nginx" 36 | 37 | # optional 38 | backup_blocklist=${BACKUP_BLOCKLIST:-"null"} # -l "media_vol,/media-bind" 39 | update_requested=false # -u 40 | version_requested=false # -v 41 | 42 | # internal 43 | timestamp=$(date +"%Y%m%d-%H%M%S") 44 | stack_running=false 45 | working_dir="null" 46 | volume_blocklist=() 47 | path_blocklist=() 48 | running_container_ids="" 49 | running_container_names="" 50 | 51 | # script 52 | # --- 53 | 54 | main() { 55 | # script version check 56 | if [ "$version_requested" = true ]; then 57 | script_update_check 58 | exit 0 59 | fi 60 | 61 | # check current directory for compose file 62 | docker_stack_dir 63 | 64 | # check script variables before continuing 65 | verify_config 66 | 67 | # create backup directory 68 | mkdir -p "$backup_dir/$stack_name" || { echo "Error, failed to create backup directory $backup_dir!"; exit 1; } 69 | 70 | # stop stack before backup 71 | echo "(stop)" 72 | docker_stack_stop 73 | 74 | # backup compose stack working directory 75 | echo "(backups)" 76 | backup_working_dir 77 | 78 | # backup docker volumes 79 | backup_stack_volumes 80 | 81 | # update if requested 82 | if [ "$update_requested" = true ]; then 83 | echo "(updates)" 84 | 85 | # print stack changelog url 86 | # print_changelog_url 87 | 88 | # update compose stack 89 | docker_stack_update 90 | fi 91 | 92 | # restart stack again if previously running 93 | echo "(restart)" 94 | docker_stack_start 95 | 96 | # prune unused docker images 97 | if [ "$update_requested" = true ]; then 98 | echo "(prune)" 99 | # new images must be associated with a running stack 100 | if [ "$stack_running" = true ]; then 101 | docker_image_prune 102 | else 103 | echo "- Docker stack was not recreated, skipping image prune" 104 | echo 105 | fi 106 | fi 107 | 108 | echo -e "backupdate complete!\n\n" 109 | exit 0 110 | } 111 | 112 | # utilities 113 | # --- 114 | 115 | usage() { 116 | echo "Usage: $0 [-b backup_dir] [-d docker_dir] [-s stack_name] [-l backup_blocklist] [-u] [-v]" 117 | echo " --backup-dir --docker-dir --stack-name --backup-blocklist --update --version" 118 | exit 1 119 | } 120 | 121 | parse_args() { 122 | local OPTIONS=b:d:s:l:uv 123 | local LONGOPTS=backup-dir:,docker-dir:,stack-name:,backup-blocklist:,update,version 124 | 125 | # parse options 126 | if ! PARSED=$(getopt --options=$OPTIONS --longoptions=$LONGOPTS --name "$0" -- "$@"); then 127 | exit 2 128 | fi 129 | 130 | # evaluate parsed options 131 | eval set -- "$PARSED" 132 | 133 | # Now handle the options 134 | while true; do 135 | case "$1" in 136 | -b|--backup-dir) 137 | backup_dir="$2" 138 | shift 2 139 | ;; 140 | -d|--docker-dir) 141 | docker_dir="$2" 142 | shift 2 143 | ;; 144 | -s|--stack-name) 145 | stack_name="$2" 146 | shift 2 147 | ;; 148 | -l|--backup-blocklist) 149 | backup_blocklist="$2" 150 | shift 2 151 | ;; 152 | -u|--update) 153 | update_requested=true 154 | shift 155 | ;; 156 | -v|--version) 157 | version_requested=true 158 | shift 159 | ;; 160 | --) 161 | shift 162 | break 163 | ;; 164 | *) 165 | echo "Unknown option: $1" 166 | exit 3 167 | ;; 168 | esac 169 | done 170 | } 171 | 172 | verify_config() { 173 | # check required inputs 174 | if [ "$backup_dir" = "null" ]; then 175 | echo "Error, backup_dir not provided!" 176 | usage 177 | fi 178 | if [ "$working_dir" = "null/$stack_name" ]; then 179 | echo "Error, docker_dir not provided!" 180 | usage 181 | fi 182 | if [ "$stack_name" = "null" ]; then 183 | echo "Error, stack_name not provided!" 184 | usage 185 | fi 186 | if [ "$working_dir" = "null" ]; then 187 | echo "Error, working_dir not set!" 188 | exit 1 189 | fi 190 | 191 | # echo script config 192 | echo "backupdate <$stack_name> $timestamp" 193 | echo "- backup_dir: $backup_dir" 194 | echo "- working_dir: $working_dir" 195 | 196 | # check backup blocklist 197 | if [ "$backup_blocklist" != "null" ]; then 198 | # convert string to array 199 | IFS=',' read -r -a blockarray <<< "$backup_blocklist" 200 | 201 | # process items in array 202 | for item in "${blockarray[@]}"; do 203 | if [[ $item == /* ]]; then 204 | # item starts with slash 205 | item="${item#/}" 206 | path_blocklist+=("$item") 207 | else 208 | volume_blocklist+=("$item") 209 | fi 210 | done 211 | 212 | # echo volume blocklist 213 | if [ ${#volume_blocklist[@]} -gt 0 ]; then 214 | echo "- volume_blocklist:" 215 | for vol in "${volume_blocklist[@]}"; do 216 | echo -e "\t- $vol" 217 | done 218 | fi 219 | 220 | # echo path blocklist 221 | if [ ${#path_blocklist[@]} -gt 0 ]; then 222 | echo "- path_blocklist:" 223 | for path in "${path_blocklist[@]}"; do 224 | echo -e "\t- $path" 225 | done 226 | fi 227 | fi 228 | 229 | echo 230 | } 231 | 232 | confirm() { 233 | local prompt="$1" 234 | read -r -p "${prompt} (y/N): " confirm 235 | if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then 236 | return 0 # true 237 | else 238 | return 1 # false 239 | fi 240 | } 241 | 242 | script_update_check() { 243 | local repo="hazzuk/compose-backupdate" 244 | local raw_url="https://raw.githubusercontent.com/$repo/refs/heads/release/backupdate.sh" 245 | local latest_version_line 246 | local latest_version 247 | 248 | # fetch second line (version="X.Y.Z") from the script hosted on github 249 | latest_version_line=$(curl -s "$raw_url" | sed -n '2p') 250 | # extract version from the fetched line 251 | latest_version=$(echo "$latest_version_line" | grep -oP '(?<=version=")[^"]+') 252 | 253 | if [[ $latest_version == "" ]]; then 254 | echo "Warn, could not check for updates" 255 | return 0 256 | fi 257 | 258 | # compare local version with the latest version 259 | if [[ "$version" != "$latest_version" ]]; then 260 | echo "A new version (v$latest_version) is available! You are using backupdate-v$version" 261 | else 262 | echo "Running backupdate-v$version" 263 | fi 264 | } 265 | 266 | # docker 267 | # --- 268 | 269 | docker_stack_stop() { 270 | local container_ids 271 | local container_names 272 | 273 | echo "Stopping <$stack_name> containers" 274 | cd "$working_dir" || exit 275 | 276 | # for updates, require stack to be removed 277 | if [ "$update_requested" = true ]; then 278 | stack_running=false 279 | 280 | # stop stack 281 | echo "- Update requested, removing all stack containers" 282 | docker compose down 283 | 284 | else 285 | # check stack running, with at least one container running 286 | if docker compose ls --quiet --filter "name=$stack_name" | grep -q "$stack_name"; then 287 | stack_running=true 288 | 289 | # get running containers ids 290 | container_ids=$(docker compose ps --quiet --filter "status=running") 291 | 292 | # get running containers names 293 | if [ -n "$container_ids" ]; then 294 | # shellcheck disable=SC2086 295 | container_names=$(docker inspect --format '{{.Name}}' $container_ids | sed 's|^/||' | tr -d '\r') 296 | 297 | # print container names 298 | for name in $container_names; do 299 | echo "- $name" 300 | done 301 | 302 | # set global variables 303 | running_container_ids=$container_ids 304 | running_container_names=$container_names 305 | 306 | else 307 | echo "Error, stack running but no container IDs found!" 308 | exit 1 309 | fi 310 | 311 | # stop stack 312 | docker compose --progress "quiet" stop 313 | 314 | else 315 | stack_running=false 316 | echo "- Docker stack <$stack_name> not running, skipping docker stop" 317 | fi 318 | fi 319 | echo 320 | } 321 | 322 | docker_stack_start() { 323 | # check stack was previously running 324 | if [ "$stack_running" = true ]; then 325 | cd "$working_dir" || exit 326 | 327 | echo "Resuming <$stack_name> containers" 328 | 329 | # restart only previously running containers 330 | if [ -n "$running_container_ids" ]; then 331 | 332 | # print container names 333 | for name in $running_container_names; do 334 | echo "- $name" 335 | done 336 | 337 | # restart containers 338 | echo -e "\nInfo, restarted container IDs" 339 | # shellcheck disable=SC2086 340 | docker start $running_container_ids 341 | fi 342 | 343 | else 344 | # for updates, stack should be recreated with updated images 345 | if [ "$update_requested" = true ]; then 346 | if confirm "Do you want to recreate <$stack_name>'s containers now?"; then 347 | echo "- Recreating Docker stack..." 348 | docker compose up -d 349 | 350 | # new stack running, can prune unused images 351 | stack_running=true 352 | else 353 | echo "- Stack recreation canceled" 354 | fi 355 | 356 | # stack was not previously running 357 | else 358 | echo "- Docker stack <$stack_name> not previously running, skipping docker start" 359 | fi 360 | fi 361 | echo 362 | } 363 | 364 | docker_stack_dir() { 365 | # possible compose file names 366 | local compose_files=("compose.yaml" "compose.yml" "docker-compose.yaml" "docker-compose.yml") 367 | # current directory name 368 | local current_dir 369 | current_dir=$(basename "$PWD") # "nginx"? 370 | 371 | # check neither $docker_dir or $stack_name were provided 372 | if [[ "$docker_dir" == "null" && "$stack_name" == "null" ]]; then 373 | echo "Info, neither docker_dir or stack_name were provided, using current directory" 374 | # but if $docker_dir was provided alone (likely as an environment variable), and is correct 375 | elif [[ "$docker_dir/$current_dir" = "$(pwd)" && "$stack_name" == "null" ]]; then 376 | echo "Info, stack_name was not provided, using current directory" 377 | # otherwise something was provided, do not use current directory 378 | else 379 | # update working_dir with provided options 380 | working_dir="$docker_dir/$stack_name" 381 | return 0 382 | fi 383 | 384 | # search current directory for compose file 385 | for file in "${compose_files[@]}"; do 386 | if [[ -f "$file" ]]; then 387 | # update working_dir and stack_name to current directory 388 | working_dir="$(pwd)" 389 | stack_name=$current_dir 390 | echo -e "Found <$stack_name> $file in current directory\n " 391 | return 0 392 | fi 393 | done 394 | echo "Error, compose file not found in current directory!" 395 | usage 396 | } 397 | 398 | docker_stack_update() { 399 | if confirm "Are you sure you want to update <$stack_name>?"; then 400 | echo "- Updating Docker stack..." 401 | docker compose pull 402 | else 403 | echo "- Update canceled" 404 | fi 405 | echo 406 | } 407 | 408 | docker_image_prune() { 409 | local docker_images 410 | local docker_images_unused=() 411 | 412 | echo "Searching for unused docker images..." 413 | # collect docker images output 414 | docker_images=$( 415 | docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}" | \ 416 | tail -n +2 417 | ) 418 | 419 | # process images output 420 | while read -r image_id repository tag size; do 421 | # skip unused busybox image 422 | if [[ "$repository" == "busybox" ]]; then 423 | continue 424 | fi 425 | # check the image is not being used by a running/stopped container 426 | if [[ -z $(docker ps -a --filter "ancestor=$image_id" --format '{{.ID}}') ]]; then 427 | # append unused image_id to array 428 | docker_images_unused+=("$image_id") 429 | # print unused image details 430 | printf "%-16s %-45s %-10s\n" "- $image_id" "$repository:$tag" "$size" 431 | fi 432 | done <<< "$docker_images" # avoid subshell 433 | 434 | # check no unused images found 435 | if [[ ${#docker_images_unused[@]} -eq 0 ]]; then 436 | echo -e "- No unused images found\n " 437 | return 0 438 | else 439 | # prompt user for confirmation before proceeding 440 | if confirm "Do you want to prune unused images?"; then 441 | # prune unused images 442 | for image_id in "${docker_images_unused[@]}"; do 443 | echo "- Removing $image_id" 444 | docker rmi "$image_id" -f 445 | done 446 | else 447 | echo "- Prune cancelled" 448 | fi 449 | fi 450 | echo 451 | } 452 | 453 | # backups 454 | # --- 455 | 456 | backup_working_dir() { 457 | local exclude_opts="" 458 | local exclude_info="" 459 | 460 | echo "Backup <$stack_name> directory: $working_dir" 461 | 462 | # set blocklist options 463 | if [ ${#path_blocklist[@]} -gt 0 ]; then 464 | for path in "${path_blocklist[@]}"; do 465 | exclude_opts+="--exclude=$path " 466 | exclude_info+="$path " 467 | done 468 | echo "- Skipping blocklisted paths: $exclude_info" 469 | fi 470 | 471 | # create archive with exclude options 472 | eval tar -czf "$backup_dir/$stack_name/d-$stack_name-$timestamp.tar.gz" "$exclude_opts" -C "$working_dir" . 473 | echo "- Directory backup complete" 474 | } 475 | 476 | backup_stack_volumes() { 477 | # get all stack volumes 478 | local stack_volumes 479 | stack_volumes=$( 480 | docker volume ls --filter "label=com.docker.compose.project=$stack_name" --format "{{.Name}}" 481 | ) 482 | 483 | # check volumes found 484 | if [ -z "$stack_volumes" ]; then 485 | echo -e "Info, no related volumes found for <$stack_name>\n " 486 | return 0 487 | fi 488 | 489 | # backup each volume 490 | for volume_name in $stack_volumes; do 491 | echo "Backup volume: <$volume_name>" 492 | 493 | # skip blocklisted volumes 494 | if [[ " ${volume_blocklist[*]} " == *" $volume_name "* ]]; then 495 | echo "- Skipping blocklisted volume" 496 | continue 497 | fi 498 | # create backup 499 | backup_volume "$volume_name" 500 | done 501 | echo 502 | } 503 | 504 | backup_volume() { 505 | local volume_name=$1 506 | 507 | # backup volume data with temporary container 508 | docker run --rm \ 509 | -v "$volume_name":/volume_data \ 510 | -v "$backup_dir":/backup \ 511 | busybox tar czf "/backup/$stack_name/v-$volume_name-$timestamp.tar.gz" -C /volume_data . || \ 512 | { echo "Error, failed to create busybox backup container!"; exit 1; } 513 | echo "- Volume backup complete" 514 | } 515 | 516 | # print_changelog_url() { 517 | # local changelog_file="$working_dir/changelog.url" 518 | 519 | # # check changelog.url exists 520 | # if [[ -f "$changelog_file" ]]; then 521 | # echo "Link to read the <$stack_name> changelog: " 522 | # cat "$changelog_file" 523 | # else 524 | # # ask user to create changelog.url 525 | # echo "File $changelog_file does not exist" 526 | # read -r -p "Please provide a URL (or press Enter to continue without): " user_input 527 | 528 | # if [[ $user_input == http* ]]; then 529 | # # create changelog.url with user input 530 | # echo "$user_input" > "$changelog_file" 531 | # echo "- $changelog_file created" 532 | # else 533 | # echo "- No valid URL provided. Continuing without the <$stack_name> changelog" 534 | # fi 535 | # fi 536 | # } 537 | 538 | # run script 539 | # --- 540 | 541 | parse_args "$@" 542 | main 543 | --------------------------------------------------------------------------------