├── .gitignore ├── Dockerfile ├── LICENCE.md ├── README.md ├── backup.sh ├── common.sh ├── entrypoint.sh └── restore.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stretch 2 | 3 | MAINTAINER Tim Bennett 4 | 5 | RUN \ 6 | apt-get update && \ 7 | apt-get install -y mysql-client cron sqlite3 curl jq netcat 8 | 9 | # ----------------------- 10 | # Default configuration 11 | # ----------------------- 12 | 13 | # Location for storing backup archives 14 | ENV BACKUP_LOCATION "/backups" 15 | 16 | # Number of recent backups to retain (one backup = one db archive and one files archive) 17 | ENV BACKUPS_RETAIN_LIMIT 30 18 | 19 | # Location of backup log (written after each automated backup) 20 | ENV LOG_LOCATION "/var/log/ghost-backup.log" 21 | 22 | # Backup daily at 3am 23 | ENV BACKUP_TIME 0 3 * * * 24 | 25 | # Whether to install the crontab or not 26 | ENV AUTOMATED_BACKUPS true 27 | 28 | # Ghost files location 29 | ENV GHOST_LOCATION "/var/lib/ghost" 30 | 31 | # Prefix to put before all backed up files and archives 32 | ENV BACKUP_FILE_PREFIX="backup" 33 | 34 | # Service name to expect for mysql connections (if applicable). If you're using ghost-backup with 35 | # a mysql/mariadb database then your db service must be available on the network using this name 36 | ENV MYSQL_SERVICE_NAME="mysql" 37 | 38 | # Service port for mysql connections (if applicable) 39 | ENV MYSQL_SERVICE_PORT=3306 40 | 41 | # Name of sqlite database (if applicable) 42 | ENV SQLITE_DB_NAME="ghost.db" 43 | 44 | # The client slug used to auth with the api to import/export the json file 45 | ENV CLIENT_SLUG="ghost-backup" 46 | 47 | # Service name to expect for ghost connections. If using json file backup/restore then your ghost service must be 48 | # available on the network at this address 49 | ENV GHOST_SERVICE_NAME="ghost" 50 | 51 | # Service port for ghost connections (if applicable) 52 | ENV GHOST_SERVICE_PORT="2368" 53 | 54 | # ----------------------- 55 | 56 | RUN mkdir $BACKUP_LOCATION 57 | 58 | VOLUME $BACKUP_LOCATION 59 | 60 | # Setup the entrypoint script for initiating the crontab 61 | COPY entrypoint.sh /entrypoint.sh 62 | RUN chmod +x /entrypoint.sh 63 | 64 | # Add the backup/restore scripts 65 | COPY backup.sh /bin/backup 66 | COPY restore.sh /bin/restore 67 | COPY common.sh /bin/common.sh 68 | RUN chmod +x /bin/backup 69 | RUN chmod +x /bin/restore 70 | 71 | # Clean up 72 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 73 | 74 | ENTRYPOINT ["/entrypoint.sh"] 75 | 76 | # Run cron and continually watch the ghost backup log file 77 | CMD ["sh", "-c", "touch $LOG_LOCATION && cron && tail -F $LOG_LOCATION"] 78 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Tim Bennett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghost-backup 2 | 3 | ghost-backup is a simple, automated, backup (and restore) [Docker] container for a [Ghost] blog. It supports Ghost configured with either sqlite or mysql/[mariadb]. 4 | 5 | ghost-backup can: 6 | 7 | * Take a full backup of your ghost blog with a single `backup` command 8 | * Database backup (mysql or mariadb) 9 | * Content files backup (images, themes etc) 10 | * Json file backup (retrieved by accessing the export feature of the ghost api) 11 | * Automate backups according to any arbitrary schedule (via cron) 12 | * Allow restore of files selectively and interactively 13 | * Be extensively customised 14 | 15 | By default it will create a backup of your ghost content directory (images, themes, apps etc), the database 16 | (actual posts), and the exported json file daily at 3am, keeping the most recent 30 backups of each. 17 | 18 | When using sqlite, the db backup/restore is handled using the [command line shell] of the [online backup API]. 19 | For mysql/mariadb, it uses [mysqldump](https://dev.mysql.com/doc/refman/5.5/en/mysqldump.html). 20 | 21 | ### Quick Start (Ghost using sqlite) 22 | Ghost uses sqlite by default if you have not changed the [configuration](https://docs.ghost.org/docs/config) to mysql. 23 | 24 | Create and run the ghost-backup container with the volumes from your Ghost data container: 25 | 26 | `docker run --name ghost-backup -d --volumes-from bennetimo/ghost-backup` 27 | 28 | Where: 29 | 30 | `` is either your Ghost blog container, or a separate data-only container/[volume] holding your blog files. Basically, wherever your blog content lives. 31 | 32 | That's it! This will create and run a container named 'ghost-backup' which will create a backup of your Ghost database and content files under `/backups` inside the `ghost-backup` container every day at 3am. 33 | 34 | > If you want json file backup also, a few more options are required 35 | 36 | The below sections walk through customizing the backup. 37 | 38 | ### Quick Start (Ghost using mysql/mariadb) 39 | 40 | If your Ghost [configuration](https://docs.ghost.org/docs/config) is using mysql/mariadb then you just need to start the ghost-backup 41 | container on the same [network] as your database container, so that it can talk to your database. 42 | 43 | ``` 44 | docker run --name ghost-backup -d \ 45 | --volumes-from \ 46 | --network= \ 47 | -e MYSQL_USER= \ 48 | -e MYSQL_PASSWORD= \ 49 | -e MYSQL_DATABASE= \ 50 | bennetimo/ghost-backup 51 | ``` 52 | 53 | Where: 54 | * `` is as above 55 | * `` is a network that your database container is connected to. It should be accessible using the hostname 'mysql' which you can set with [--network-alias] 56 | * MYSQL_ vars are the details needed to access your database 57 | 58 | > This could also be setup via [container links], but this feature is now considered legacy and deprecated. 59 | 60 | ### Configuring the backup location 61 | 62 | By default, the backups will live in `/backups` inside the `ghost-backup` container. You can verify they're 63 | there with `docker exec ghost-backup ls /backups`. 64 | 65 | To mount the backups directory somewhere on the host add: 66 | `-v :/backups` to your docker run command. 67 | 68 | To use [docker volumes](https://docs.docker.com/storage/volumes/), first create the volume, then attach it to both the ghost container 69 | and backup container. See the bottom of this readme for an example docker-compose 70 | configuration using volumes. 71 | 72 | > To change the backups folder used in the container set the env var: BACKUP_LOCATION=/your/new/location 73 | 74 | ### Ghost json file backup/restore setup 75 | 76 | Ghost labs has had a feature to [export your blog content](https://help.ghost.org/article/13-import-export) as a single json file for a long time. Since version 77 | 1.x+ it is also possible to [import a json file](https://docs.ghost.org/docs/migrating-to-ghost-1-0-0#section-3-use-the-ghost-1-0-0-importer). This is a mandatory step 78 | when upgrading from ghost 0.x to 1.x as the database format changed. ghost-backup can be configured to export/import this json file using the exact same api that it used when 79 | you initiate this manually via http://yourghostblog/ghost/labs. 80 | 81 | > If you import a json file twice, all posts will be duplicated. The API does not seem to currently filter out duplicate posts so be careful 82 | 83 | To use the json api, ghost-backup needs to authenticate and obtain an [access token](https://api.ghost.org/docs/user-authentication#retrieve-a-bearer-token-via-curl ), and needs to be able to 84 | communicate with your ghost service. 85 | 86 | You need to configure the following additional environment variables, so that an access token can be retrieved: 87 | 88 | * GHOST_SERVICE_USER_EMAIL # The email address of a user configured in your ghost installation (N.B. this should be uri encoded, e.g. my-email.%40example.com) 89 | * GHOST_SERVICE_USER_PASSWORD # The password for that user 90 | 91 | > A good idea would be to create a new user in your ghost admin panel specifically for ghost-backup and use those credentials here 92 | 93 | ghost-backup expects to be able to communicate with your ghost service via the hostname `ghost` using the 94 | default port of `2368`. If you need to override these, you can override the env vars: 95 | 96 | * GHOST_SERVICE_NAME 97 | * GHOST_SERVICE_PORT 98 | 99 | To use the json api a [client id and secret](https://api.ghost.org/docs/client-authentication) is also required. With a standard ghost install there are several clients preconfigured, 100 | including 'ghost-frontend' and 'ghost-backup'. Each of these gets a secret randomly generated and put into the database. By default 101 | ghost-backup will use the 'ghost-backup' client and read the corresponding secret from the database so you do not need to configure this. 102 | However if you need to override the client used for any reason, you can set the `CLIENT_SLUG` for your ghost-backup container. 103 | 104 | A full configuration with support for json import/export might look like this: 105 | 106 | ``` 107 | docker run --name ghost-backup -d \ 108 | --volumes-from \ 109 | --network= \ 110 | -e MYSQL_USER= \ 111 | -e MYSQL_PASSWORD= \ 112 | -e MYSQL_DATABASE= \ 113 | -e GHOST_SERVICE_USER_EMAIL= \ 114 | -e GHOST_SERVICE_USER_PASSWORD= \ 115 | bennetimo/ghost-backup 116 | ``` 117 | 118 | ### Perform a manual backup 119 | `docker exec ghost-backup backup` 120 | 121 | This will create an immediate backup. You should now have backup files created in the backup folder (`/backups` by default). 122 | One archive is the database, one the archive of your content files, and if configured also a json export of your ghost blog. 123 | 124 | >Note that backups are tagged with the date and time in the form yyyymmdd-hhmm, therefore if two backups are created in the same minute then the second will overwrite the first. 125 | 126 | ### Restore a backup 127 | A backup is no good if it can't be restored :) You can do that in three ways: 128 | 129 | > N.B. After a database restore you will likely need to restart your ghost block container to see the changes 130 | 131 | #### Interactive restore 132 | You can launch an interactive backup menu using: 133 | `docker exec -it ghost-backup restore -i` 134 | This will display a menu with all of the available backup files. You can select which to restore by number or name. 135 | 136 | > Using interactive backup you can restore a DB archive separately to a Ghost files archive 137 | 138 | #### By date restore 139 | You can also restore by date: 140 | 141 | `docker exec ghost-backup restore -d yyyymmdd-hhmm` 142 | This will restore the backup files from yyyymmdd-hhmm, if found. 143 | 144 | > Date restore expects to find both a db and content files archive for the corresponding date, or will stop. 145 | If you want to restore just one of the other (or a json file), use either file restore or interactive restore 146 | 147 | #### By file restore 148 | You can restore a given file mounted to the container: 149 | 150 | `docker exec ghost-backup restore -f /path/to/file/filename` 151 | 152 | > N.B. Be sure to use fully qualified path names when restoring a single file 153 | 154 | #### In place restore 155 | By default the restore script will remove the ghost files from `GHOST_LOCATION/content` before restoring the archive, except for the database which is handled separately. 156 | 157 | To restore without removing files first you can specify the command argument capitalised, e.g. `-I, -D, -F`. 158 | 159 | #### Matching files to restore 160 | 161 | ghost-backup uses the following matches to determine whether a file to restore is a db archive, content archive, or json file: 162 | 163 | ``` 164 | DB_ARCHIVE_MATCH="${BACKUP_FILE_PREFIX}.*db.*gz" 165 | GHOST_ARCHIVE_MATCH="${BACKUP_FILE_PREFIX}.*ghost.*tar" 166 | GHOST_JSON_FILE_MATCH="${BACKUP_FILE_PREFIX}.*ghost.*json" 167 | ``` 168 | 169 | If you rename your backup files, they must match these patterns to be able to restore. 170 | 171 | ### Advanced Configuration 172 | ghost-backup has a number of options which can be configured as you need. 173 | 174 | | Environment Variable | Default | Meaning | 175 | | --------------------- | ------------- | ----------------- | 176 | | BACKUP_TIME | 0 3 * * * | A [cron expression] controlling the backup schedule.| 177 | | BACKUP_LOCATION | /backups | Where the backups are written to| 178 | | BACKUPS_RETAIN_LIMIT | 30 | How many backups to keep. Oldest are removed first| 179 | | LOG_LOCATION | /var/log/ghost-backup.log | Location of the log file | 180 | | AUTOMATED_BACKUPS | true | Whether scheduled backups are on | 181 | | GHOST_LOCATION | /var/lib/ghost/content | Location of ghost content and db files | 182 | | BACKUP_FILE_PREFIX | backup | Prefix for all created backup files | 183 | | MYSQL_SERVICE_NAME | mysql | Hostname of mysql container (if applicable) | 184 | | MYSQL_SERVICE_PORT | 3306 | Port of mysql container (if applicable) | 185 | | SQLITE_DB_NAME | ghost.db | Name of sqlite database (if applicable) | 186 | | CLIENT_SLUG | ghost-backup | client used for authenticating with the ghost json api | 187 | | GHOST_SERVICE_NAME | ghost | Hostname of ghost container (if applicable) | 188 | | GHOST_SERVICE_PORT | 2368 | Port of ghost container | 189 | 190 | 191 | For example, if you wanted to backup at 2AM to the location /some/dir/backups, storing 10 days of backups you would use: 192 | 193 | ``` 194 | docker run --name ghost-backup -d \ 195 | --volumes-from \ 196 | -e "BACKUP_LOCATION=/some/dir/backups" \ 197 | -e "BACKUP_TIME=0 2 * * *" \ 198 | -e "BACKUPS_RETAIN_LIMIT=10" \ 199 | bennetimo/ghost-backup 200 | ``` 201 | 202 | > This example is for Ghost using sqlite. If you're using mysql/mariadb just add the linked mysql containers as described above. 203 | 204 | #### Disable backup types 205 | 206 | By default, the backup will have a Ghost content files archive, a DB archive, an exported json file 207 | (if connected to your ghost service) and purge any excess old backups specified by the `BACKUPS_RETAIN_LIMIT`. 208 | Each of these can be disabled with command arguments: 209 | 210 | * -D //Do not include a DB archive 211 | * -F //Do not include a ghost content files archive 212 | * -J //Do not include a ghost json file export 213 | * -P //Do not purge old files 214 | 215 | For example to perform a backup of just the DB with no purge: 216 | 217 | `docker exec ghost-backup backup -FJP` 218 | 219 | ### Backup to Dropbox 220 | You can configure the backup location as you wish, which if used in conjunction with [bennetimo/docker-dropbox] will backup to a [Dropbox] folder. 221 | 222 | To do this, you need to have a dropbox container running, linked to your account: 223 | `docker run -d --name dropbox bennetimo/docker-dropbox` 224 | 225 | > You need to link this container to your Dropbox account first, see [docker-dropbox quickstart] 226 | 227 | Then create your backup container using the Dropbox volume: 228 | ``` 229 | docker run --name ghost-backup -d \ 230 | --volumes-from \ 231 | --volumes-from \ 232 | -e "BACKUP_LOCATION=/root/Dropbox" 233 | bennetimo/ghost-backup` 234 | ``` 235 | 236 | That's it. Now if your Dropbox container has been linked correctly to your account you'll have a backup of your blog added every day at 3am to your Dropbox. 237 | 238 | ### View the logs 239 | `docker logs ghost-backup` 240 | Will display logs of all of backup runs (manual and automated) and restore operations. By default the log file is at: `/var/log/ghost-backup.log` 241 | 242 | ### Disabling automated backups 243 | If you want to disable automated backups and just perform them manually as necessary, then you can stop the crontab installation by starting your container as: 244 | 245 | ``` 246 | docker run -d --name ghost-backup \ 247 | --volumes-from 248 | -e "AUTOMATED_BACKUPS=false" bennetimo/ghost-backup 249 | ``` 250 | 251 | Now you can run: 252 | `docker exec ghost-backup backup` 253 | 254 | Every time you want to take a backup. You can restore as normal (described above). 255 | 256 | ### Using ghost-backup for cloning an environment locally 257 | You can use ghost-backup to create a local test environment for your blog, with all the posts and content. This allows 258 | you to write your posts, tweak your theme and check everything is working locally before cloning it exactly 259 | on your live blog. To do this: 260 | 261 | 1. Setup your local/dev dockerised ghost blog 262 | 2. Setup the ghost-backup container for your local blog as described in this readme, with e.g. Dropbox as the backup location 263 | 3. Create a live dockerised blog on your remote server, also with ghost-backup configured to use a suitable backups mount 264 | 265 | Now your workflow will be: 266 | 267 | 1. Write/edit content locally 268 | 2. Take a local backup with `docker exec ghost-backup backup` 269 | 3. Transfer your backup archives to your remote host (e.g. scp/DropBox) to the mounted backup location 270 | 4. On the remote host `docker exec ghost-backup restore -i` and restore your backup files 271 | 5. Restart your remote ghost blog to pick up changes 272 | 273 | ### Example Docker Compose Configuration 274 | 275 | Using [docker-compose](https://docs.docker.com/compose/) makes it easy to configure all the requirement components. 276 | 277 | The example configuration below will startup a ghost container, mariadb container and ghost-backup container 278 | all on the same network so that ghost backup can work. 279 | 280 | Then: 281 | 282 | 1. `docker-compose up` 283 | 1. View your blog at [http://localhost:2368/](http://localhost:2368/) 284 | 1. Take a backup with `docker exec ghost-backup backup` 285 | 286 | > When first starting up, ghost may try to connect to the mysql container before it is ready for connections generating 287 | a few error messages. After a few tries it will succeed, or to avoid this you can start the mysql container separately first, 288 | or do something else to [control startup order](https://docs.docker.com/compose/startup-order/) 289 | 290 | ``` 291 | version: "3.7" 292 | 293 | services: 294 | # Ghost container 295 | ghost: 296 | image: ghost:1.25 297 | restart: always 298 | ports: 299 | - "2368:2368" 300 | environment: 301 | - database__client=mysql 302 | - database__connection__host=mysql 303 | - database__connection__database=ghost 304 | - database__connection__user=yourdbuser 305 | - database__connection__password=yourdbpassword 306 | volumes: 307 | - "data-ghost-content:/var/lib/ghost/content" 308 | 309 | # Database container 310 | mysql: 311 | image: mariadb:10.3 312 | restart: always 313 | environment: 314 | - MYSQL_ROOT_PASSWORD=myrootpassword 315 | - MYSQL_USER=yourdbuser 316 | - MYSQL_PASSWORD=yourdbpassword 317 | - MYSQL_DATABASE=ghost 318 | expose: 319 | - "3306" 320 | volumes: 321 | - "data-ghost-db:/var/lib/mysql" 322 | 323 | # Ghost backup container 324 | ghost-backup: 325 | image: bennetimo/ghost-backup:1.25 326 | container_name: "ghost-backup" 327 | environment: 328 | - MYSQL_USER=yourdbuser 329 | - MYSQL_PASSWORD=yourdbpassword 330 | - MYSQL_DATABASE=ghost 331 | volumes: 332 | - "data-ghost-content:/var/lib/ghost/content" 333 | 334 | # Data volumes containing all the persistent storage for the blog 335 | volumes: 336 | data-ghost-content: 337 | data-ghost-db: 338 | ``` 339 | 340 | > N.B. The above is shown as a self contained file for completeness. But, you'd probably want to look at using 341 | [env_files](https://docs.docker.com/compose/environment-variables/#the-env_file-configuration-option) or 342 | [multiple compose files](https://docs.docker.com/compose/extends/#multiple-compose-files) for better separation 343 | 344 | ### Versions 345 | 346 | Ghost 0.x to 1.x introduced some breaking changes so backups and restores between them are not possible without a little work. 347 | 348 | ghost-backup 0.7.3 is an earlier version of this container for ghost 0.x releases. 349 | 350 | ghost-backup 1.x is for ghost 1.x+ releases. 351 | 352 | #### Migrating from ghost 0.x to 1.x+ 353 | 354 | Follow ghosts [migration guide](https://docs.ghost.org/docs/migrating-to-ghost-1-0-0). 355 | 356 | Database backups will *not* be compatible between these two major versions. However *the json backup is*. 357 | 358 | For content files that used to live under `/var/lib/ghost` in 0.x moved to `/var/lib/ghost/content` in 1.x, as 359 | well as there being a few other changes with config/themes etc. 360 | 361 | ### Other Info 362 | 363 | This container was inspired by [wordpress-backup]. 364 | 365 | **Disclaimer:** Will not be held responsible for any loss of files/backups arising from your use of this 366 | container. **Be sure to test your backup/restore process when you first set everything up to make sure it 367 | is all working as you expect.** 368 | 369 | [Docker]: https://www.docker.com/ 370 | [volume]: https://docs.docker.com/storage/volumes/ 371 | [container links]: https://docs.docker.com/network/links/ 372 | [network]: https://docs.docker.com/engine/reference/commandline/network_create/#extended-description 373 | [--network-alias]: https://docs.docker.com/engine/reference/run/#network-settings 374 | [Ghost]: https://ghost.org/ 375 | [cron expression]: https://en.wikipedia.org/wiki/Cron#Format 376 | [Dropbox]: https://www.dropbox.com/ 377 | [bennetimo/docker-dropbox]: https://hub.docker.com/r/bennetimo/docker-dropbox/ 378 | [docker-dropbox quickstart]: https://github.com/bennetimo/docker-dropbox#quick-start 379 | [mariadb]: https://hub.docker.com/_/mariadb/ 380 | [command line shell]: https://www.sqlite.org/cli.html 381 | [online backup API]: https://www.sqlite.org/backup.html 382 | [wordpress-backup]: https://hub.docker.com/r/aveltens/wordpress-backup/ 383 | [client authentication]: https://api.ghost.org/docs/client-authentication 384 | 385 | -------------------------------------------------------------------------------- /backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | #Load common vars 6 | source common.sh 7 | 8 | usage() { echo "Usage: backup [-F (exclude ghost content files)] [-J (exclude ghost json file)] [-D (exclude db)] [-P (do not purge old backups)]" 1>&2; exit 0; } 9 | 10 | # Backup the ghost DB (either sqlite3 or mysql) 11 | backupDB () { 12 | # Test the env that is set if a mysql container is linked 13 | export_file=$BACKUP_LOCATION/$BACKUP_FILE_PREFIX-db_$NOW.gz 14 | if [ $MYSQL_CONTAINER_LINKED = true ]; then 15 | # mysql/mariadb 16 | 17 | log " creating ghost db archive (mysql)..." 18 | mysqldump --host=$MYSQL_SERVICE_NAME --port=$MYSQL_SERVICE_PORT --single-transaction --user=$MYSQL_USER --password=$MYSQL_PASSWORD $MYSQL_DATABASE | 19 | gzip -c > $export_file 20 | 21 | else 22 | # sqlite 23 | 24 | log " creating ghost db archive (sqlite)..." 25 | cd $GHOST_LOCATION/content/data && sqlite3 $SQLITE_DB_NAME ".backup temp.db" && gzip -c temp.db > $export_file && rm temp.db 26 | fi 27 | 28 | log " ...completed: $export_file" 29 | } 30 | 31 | # Backup the ghost static files (images, themes, apps etc) but not the /data directory (the db backup handles that) 32 | backupGhost () { 33 | log " creating ghost content files archive..." 34 | export_file="$BACKUP_LOCATION/$BACKUP_FILE_PREFIX-ghost_$NOW.tar.gz" 35 | #Exclude /content/data (we back that up separately), current and versions (Ghost source files from docker image), and content.orig (created when Ghost was built) 36 | tar cfz $export_file --directory=$GHOST_LOCATION --exclude='content/data' --exclude='content.orig' --exclude='current' --exclude='versions' . 2>&1 | tee -a $LOG_LOCATION 37 | log " ...completed: $export_file" 38 | } 39 | 40 | # Backup the ghost static files (images, themes, apps etc) but not the /data directory (the db backup handles that) 41 | backupGhostJsonFile () { 42 | export_file="$BACKUP_LOCATION/$BACKUP_FILE_PREFIX-ghost_$NOW.json" 43 | 44 | checkGhostAvailable 45 | 46 | if [ $GHOST_CONTAINER_LINKED = true ]; then 47 | retrieveClientSecret 48 | retrieveClientBearerToken 49 | log " ...downloading ghost json file..." 50 | curl --silent -L -o $export_file http://$GHOST_SERVICE_NAME:$GHOST_SERVICE_PORT/ghost/api/v0.1/db?access_token=$BEARER_TOKEN 51 | log " ...completed: $export_file" 52 | else 53 | log " ...skipping: Your ghost service was not found on the network. Configure GHOST_SERVICE_NAME and GHOST_SERVICE_PORT" 54 | fi 55 | 56 | } 57 | 58 | # Purge the backups directory so we only keep the most recent backups 59 | purgeOldBackups () { 60 | log "purging old backups (set to retain the most recent $BACKUPS_RETAIN_LIMIT)" 61 | # Keep only the most recent number of db archives 62 | purgeFiles $DB_ARCHIVE_MATCH "database" 63 | # Keep only the most recent number of ghost content archives 64 | purgeFiles $GHOST_ARCHIVE_MATCH "ghost content archive" 65 | # Keep only the most recent number of ghost json files 66 | purgeFiles $GHOST_JSON_FILE_MATCH "ghost json" 67 | } 68 | 69 | purgeFiles () { 70 | match=$1 71 | type=$2 72 | 73 | cd $BACKUP_LOCATION 74 | num_files=$(ls | grep "$match" | wc -l) 75 | num_purge=$((num_files-BACKUPS_RETAIN_LIMIT)) 76 | num_purge="$(( $num_purge < 0 ? 0 : $num_purge ))" 77 | 78 | log " ...found $num_files $type files (purging $num_purge)" 79 | (ls -t | grep $match | head -n $BACKUPS_RETAIN_LIMIT; ls | grep $match) | sort | uniq -u | xargs --no-run-if-empty rm 80 | } 81 | 82 | #By default do a complete backup with purging 83 | include_db=true 84 | include_files=true 85 | include_json_file=true 86 | purge=true 87 | while getopts "FDJP" opt; do 88 | case $opt in 89 | D) 90 | include_db=false 91 | log "-D set: excluding db archive in backup" 92 | ;; 93 | F) 94 | include_files=false 95 | log "-F set: excluding ghost files archive in backup" 96 | ;; 97 | J) 98 | include_json_file=false 99 | log "-J set: excluding ghost json in backup" 100 | ;; 101 | P) 102 | purge=false 103 | log "-p set: not purging old backups (limit is set to $BACKUPS_RETAIN_LIMIT)" 104 | ;; 105 | \?) 106 | usage 107 | exit 0 108 | ;; 109 | esac 110 | done 111 | 112 | # Initiate the backup 113 | log "creating backup: $NOW..." 114 | 115 | log "backing up ghost database" 116 | if [ $include_db = true ]; then backupDB; else log " ...skipped" ; fi 117 | log "backing up ghost content files" 118 | if [ $include_files = true ]; then backupGhost; else log " ...skipped" ; fi 119 | log "backing up ghost json file" 120 | if [ $include_json_file = true ]; then backupGhostJsonFile; else log " ...skipped" ; fi 121 | 122 | if [ $purge = true ]; then 123 | purgeOldBackups 124 | fi 125 | 126 | log "completed backup to $BACKUP_LOCATION" -------------------------------------------------------------------------------- /common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Sourced by ghost-backup backup and restore 4 | 5 | # Match string to indicate a db archive 6 | DB_ARCHIVE_MATCH="${BACKUP_FILE_PREFIX}.*db.*gz" 7 | # Match string to indicate a ghost archive 8 | GHOST_ARCHIVE_MATCH="${BACKUP_FILE_PREFIX}.*ghost.*tar" 9 | # Match string to indicate a ghost json export file 10 | GHOST_JSON_FILE_MATCH="${BACKUP_FILE_PREFIX}.*ghost.*json" 11 | 12 | NOW=`date '+%Y%m%d-%H%M'` 13 | 14 | # Initially set to false before being tested 15 | MYSQL_CONTAINER_LINKED=false 16 | GHOST_CONTAINER_LINKED=false 17 | 18 | # Initially client secret is empty 19 | CLIENT_SECRET= 20 | BEARER_TOKEN= 21 | 22 | # Simple log, write to stdout 23 | log () { 24 | echo "`date -u`: $1" | tee -a $LOG_LOCATION 25 | } 26 | 27 | # Check if we have a mysql container on the network to use (instead of sqlite) 28 | checkMysqlAvailable () { 29 | log "Checking if a mysql container exists on the network at $MYSQL_SERVICE_NAME:$MYSQL_SERVICE_PORT" 30 | 31 | if nc -z $MYSQL_SERVICE_NAME $MYSQL_SERVICE_PORT > /dev/null 2>&1 ; then 32 | MYSQL_CONTAINER_LINKED=true 33 | log " ...a mysql container exists on the network. Using mysql mode" 34 | 35 | # Check the appropriate env vars needed for mysql have been set 36 | if [ -z "$MYSQL_USER" ]; then log "Error: MYSQL_USER not set. Make sure it's set for your ghost-backup container?"; log "Finished: FAILURE"; exit 1; fi 37 | if [ -z "$MYSQL_DATABASE" ]; then log "Error: MYSQL_DATABASE not set. Make sure it's set for your ghost-backup container?"; log "Finished: FAILURE"; exit 1; fi 38 | if [ -z "$MYSQL_PASSWORD" ]; then log "Error: MYSQL_PASSWORD not set. Make sure it's set for your ghost-backup container?"; log "Finished: FAILURE"; exit 1; fi 39 | 40 | else 41 | log " ...no mysql container exists on the network. Using sqlite mode" 42 | fi 43 | } 44 | 45 | # Check if we have a ghost on the network to use for json file backup/restore 46 | checkGhostAvailable () { 47 | log " ...checking if a ghost container exists on the network at $GHOST_SERVICE_NAME:$GHOST_SERVICE_PORT" 48 | 49 | if nc -z $GHOST_SERVICE_NAME $GHOST_SERVICE_PORT > /dev/null 2>&1 ; then 50 | GHOST_CONTAINER_LINKED=true 51 | log " ...found ghost service on the network" 52 | else 53 | log " ...no ghost service found on the network" 54 | fi 55 | } 56 | 57 | retrieveClientSecret () { 58 | log " ...retrieving client secret for client: $CLIENT_SLUG" 59 | 60 | sql="select secret from clients where slug='$CLIENT_SLUG'" 61 | 62 | if [ $MYSQL_CONTAINER_LINKED = true ]; then 63 | CLIENT_SECRET=$(mysql --raw -s -N --host=$MYSQL_SERVICE_NAME --port=$MYSQL_SERVICE_PORT \ 64 | --user=$MYSQL_USER --password=$MYSQL_PASSWORD --database=$MYSQL_DATABASE -e "$sql") 65 | else 66 | CLIENT_SECRET=$(sqlite3 $GHOST_LOCATION/content/data/$SQLITE_DB_NAME "$sql") 67 | fi 68 | 69 | if [ -z "$CLIENT_SECRET" ]; then log "Error: Unable to retrieve the client secret for $CLIENT_SLUG from the database."; log "Finished: FAILURE"; exit 1; fi 70 | log " ...retrieved client secret: $CLIENT_SECRET for client slug: $CLIENT_SLUG" 71 | } 72 | 73 | retrieveClientBearerToken () { 74 | # Retrieve a valid bearer token so that we can call the db api (see here for more info: https://api.ghost.org/docs/user-authentication#retrieve-a-bearer-token-via-curl) 75 | BEARER_TOKEN=$(curl -s \ 76 | -H "Accept: application/json" \ 77 | -H "Content-Type: application/x-www-form-urlencoded" \ 78 | -X POST -d "grant_type=password&username=$GHOST_SERVICE_USER_EMAIL&password=$GHOST_SERVICE_USER_PASSWORD&client_id=$CLIENT_SLUG&client_secret=$CLIENT_SECRET" \ 79 | $GHOST_SERVICE_NAME:$GHOST_SERVICE_PORT/ghost/api/v0.1/authentication/token | jq -r .access_token) 80 | 81 | if [ -z "$BEARER_TOKEN" ]; then log "Error: Unable to retrieve an access token for the api. Check all your credentials are correct"; log "Finished: FAILURE"; exit 1; fi 82 | } 83 | 84 | 85 | # Run before both the backup and restore scripts 86 | checkMysqlAvailable -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [ "$AUTOMATED_BACKUPS" == "true" ]; then 6 | 7 | CRON_TAB="/etc/cron.d/ghost-backup" 8 | ENV_FILE="/root/ghost-backup-envs.sh" 9 | 10 | echo "Automated backups are on...installing crontab..." 11 | printenv | sed 's/^\(.*\)\=\(.*\)$/export \1\="\2"/g' > $ENV_FILE 12 | chmod +x $ENV_FILE 13 | (echo "$BACKUP_TIME root . $ENV_FILE; /bin/backup"; echo "") > $CRON_TAB 14 | 15 | cat $CRON_TAB 16 | fi 17 | 18 | # Create the backup folder if it doesn't exist 19 | mkdir -p $BACKUP_LOCATION 20 | 21 | echo "ghost-backup setup complete" 22 | exec "$@" -------------------------------------------------------------------------------- /restore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | #Load common vars 6 | source common.sh 7 | 8 | # Whether to extract the ghost files in place (without removing existing files first) 9 | IN_PLACE_RESTORE=false 10 | 11 | usage() { echo "Usage: restore [-i (interactive)] [-d yyyymmdd-hhmm] [-f filename]" 1>&2; exit 0; } 12 | 13 | # Restore the database from the given archive file 14 | restoreDB () { 15 | RESTORE_FILE=$1 16 | 17 | # Check if we should restore a mysql or sqlite file 18 | if [ $MYSQL_CONTAINER_LINKED = true ]; then 19 | # mysql/mariadb 20 | log "restoring data from mysql dump file: $RESTORE_FILE" 21 | # If container has been linked correctly, these environment variables should be available 22 | 23 | gunzip < $RESTORE_FILE | mysql --host=$MYSQL_SERVICE_NAME --port=$MYSQL_SERVICE_PORT --user=$MYSQL_USER --password=$MYSQL_PASSWORD $MYSQL_DATABASE || exit 1 24 | else 25 | # sqlite 26 | log "restoring data from sqlite dump file: $RESTORE_FILE" 27 | cd $GHOST_LOCATION/content/data && gunzip -c $RESTORE_FILE > temp.db && sqlite3 ghost.db ".restore temp.db" && rm temp.db 28 | fi 29 | 30 | log "...restore complete" 31 | } 32 | 33 | # Restore the ghost files (themes etc) from the given archive file 34 | restoreGhost () { 35 | RESTORE_FILE=$1 36 | 37 | if [ $IN_PLACE_RESTORE = true ]; then 38 | log "restoring ghost files from archive file: $RESTORE_FILE" 39 | tar -xzf $RESTORE_FILE --directory=$GHOST_LOCATION --keep-newer-files --warning=no-ignore-newer 2>&1 | tee -a $LOG_LOCATION 40 | else 41 | log "removing ghost files in $GHOST_LOCATION" 42 | rm -rf $GHOST_LOCATION/content/apps/ $GHOST_LOCATION/content/images/ $GHOST_LOCATION/content/settings/ $GHOST_LOCATION/content/themes/ #Do not remove /data or config.production.json 43 | log "restoring ghost files from archive file: $RESTORE_FILE" 44 | tar -xzf $RESTORE_FILE --directory=$GHOST_LOCATION --exclude='config.production.json' 2>&1 | tee -a $LOG_LOCATION 45 | fi 46 | 47 | log "...restore complete" 48 | } 49 | 50 | # Restore the database from the given json file 51 | restoreGhostJsonFile () { 52 | RESTORE_FILE=$1 53 | 54 | log "restoring data from ghost json export file: $RESTORE_FILE" 55 | 56 | checkGhostAvailable 57 | 58 | if [ $GHOST_CONTAINER_LINKED = true ]; then 59 | retrieveClientSecret 60 | retrieveClientBearerToken 61 | log " ...uploading and importing ghost json file..." 62 | curl --silent --form "importfile=@$RESTORE_FILE" -H "Authorization: Bearer $BEARER_TOKEN" $GHOST_SERVICE_NAME:$GHOST_SERVICE_PORT/ghost/api/v0.1/db 63 | else 64 | log "Error: Your ghost service was not found on the network. Configure GHOST_SERVICE_NAME and GHOST_SERVICE_PORT"; exit 1 65 | fi 66 | 67 | log "...restore complete" 68 | } 69 | 70 | # Interactively choose a DB or ghost files archive to restore 71 | chooseFile () { 72 | echo "Select DB or Ghost archive file to restore, or 'q' to quit" 73 | PS3="Restore #: " 74 | 75 | select FILENAME in $BACKUP_LOCATION/*; 76 | do 77 | [[ -z $FILENAME ]] && choice=$REPLY || choice=$FILENAME 78 | case $choice in 79 | q|Q|exit) 80 | break; 81 | ;; 82 | *) 83 | restoreFile $choice 84 | ;; 85 | \?) 86 | echo "usage..." 87 | ;; 88 | esac 89 | done 90 | } 91 | 92 | # Attempt to restore ghost and db files from a given yyyymmdd-hhmm date 93 | restoreDate () { 94 | DATE=$1 95 | GHOST_ARCHIVE="$BACKUP_LOCATION/$BACKUP_FILE_PREFIX-ghost_$DATE.tar.gz" 96 | DB_ARCHIVE="$BACKUP_LOCATION/$BACKUP_FILE_PREFIX-db_$DATE.gz" 97 | 98 | if [ ! -f $GHOST_ARCHIVE ]; then 99 | log "The ghost archive file $GHOST_ARCHIVE does not exist. Aborting." 100 | exit 1 101 | fi 102 | if [ ! -f $DB_ARCHIVE ]; then 103 | log "The ghost db archive file $DB_ARCHIVE does not exist. Aborting." 104 | exit 1 105 | fi 106 | 107 | log "Restoring ghost files and db from date: $DATE" 108 | restoreGhost $GHOST_ARCHIVE 109 | restoreDB $DB_ARCHIVE 110 | } 111 | 112 | # Determine whether file is db or ghost file and restore it 113 | restoreFile () { 114 | FILE=$1 115 | if [[ $FILE =~ .*$DB_ARCHIVE_MATCH.* ]]; then 116 | restoreDB $FILE 117 | elif [[ $FILE =~ .*$GHOST_ARCHIVE_MATCH.* ]]; then 118 | restoreGhost $FILE 119 | elif [[ $FILE =~ .*$GHOST_JSON_FILE_MATCH.* ]]; then 120 | restoreGhostJsonFile $FILE 121 | else 122 | echo "unrecognised format - the file should be either a ghost content archive, db archive, or exported ghost .json file" 123 | fi 124 | } 125 | 126 | while getopts "id:f:ID:F:" opt; do 127 | case $opt in 128 | i) 129 | chooseFile 130 | exit 0 131 | ;; 132 | I) 133 | IN_PLACE_RESTORE=true 134 | chooseFile 135 | exit 0 136 | ;; 137 | d) 138 | restoreDate ${OPTARG} 139 | exit 0 140 | ;; 141 | D) 142 | IN_PLACE_RESTORE=true 143 | restoreDate ${OPTARG} 144 | exit 0 145 | ;; 146 | f) 147 | restoreFile ${OPTARG} 148 | exit 0 149 | ;; 150 | F) 151 | IN_PLACE_RESTORE=true 152 | restoreFile ${OPTARG} 153 | exit 0 154 | ;; 155 | \?) 156 | usage 157 | exit 0 158 | ;; 159 | esac 160 | done 161 | 162 | usage 163 | --------------------------------------------------------------------------------