How to make automated backups of your Ghost blog with node.js
Published: 2021-01-02 21:02:11

> TL:DR: If you're after the source code, check out this \[Github Repository\]([https://github.com/tq-bit/ghost-backups.git](https://github.com/tq-bit/ghost-backups.git)):

Ghost, like it's big PHP - powered brother Wordpress, is a content management system. While you can find a use case (and plugin) for almost anything in WP, Ghost's approach is lean, powered by Node.js, and targets a niche of users that focusses on content creation, while offering a great developer experience. I recently started to use it for blogging and really like the API it provides. That was my motivation to build up the following project.

> Note: To follow along, you will need a dedicated instance of Ghost, including a domain and admin access. In case you don't have one but would still like to explore its functionality, there's a demo for the content endpoint. It's read-only, but it'll do. [https://demo.ghost.io/ghost/api/v3/content/posts/?key=22444f78447824223cefc48062](https://demo.ghost.io/ghost/api/v3/content/posts/?key=22444f78447824223cefc48062)

Prerequisites
-------------

The minimum requirement for you to follow along is to have a working instance of [Node.js](https://nodejs.org/en/download/) installed on your machine. I'm using a [Raspberry Pi 3+](https://www.raspberrypi.org/products/raspberry-pi-3-model-b-plus/) to do those backups for me, you'll notice it by the path to which I am saving those backups, but any other computer would to fine as well. As stated, if you do not have your own Ghost instance, you can still follow along with the [demo](https://demo.ghost.io/).

The ghost API in a nutshell
---------------------------

Ghost can be operated as a headless [headless CMS](https://en.wikipedia.org/wiki/Headless_content_management_system) by its [REST admin-endpoint](https://ghost.org/docs/admin-api/). While it also provides a read-only [content-endpoint](https://ghost.org/docs/content-api/), the former includes a variety of methods to CRUD on your content, no matter what client you're operating with. It does, however, require a few simple steps to establish a communication.

Setup a new Ghost integration
-----------------------------

> If you are following along for the content-endpoint, skip this step.

*   In your Ghost admin panel, go to _Integrations>Add Custom Integration_
*   Give that integration a name and create it.
*   When that's done, two API keys will be created. **Make sure to never share either of these keys with anybody as they provide access to your Ghost platform without user credentials.**
*   If you're using the content endpoint, you can the content key right away as a url query parameter, as shown in the note above, to make GET requests like this:

```sh
# Make a curl GET request to the ghost demo endpoint in your terminal
$ curl https://demo.ghost.io/ghost/api/v3/content/posts/?key=22444f78447824223cefc48062
```

*   In our case, we'll need to note down the admin key, as we'll use it later on for [Json Webtoken](https://jwt.io/) authentication.
*   Having done that, let us now move over to our IDE and start writing the logic.

Get started
-----------

### Prepare the project structure

Change into a directory of your choice and initialize a new npm project:

```sh
$ cd /path/to/project
$ npm init -y
```

Here, let us setup a basic structure for our app:

1.  Create two new directories.  
    Name one of them **config**, the other one **services**
2.  In the config directory, create two files.  
    One named **config.js**, the other one **util.js**
3.  In the services directory, create one file called **backupContent.js**
4.  In the root directory, create one file called **index.js**

After doing so, install the following npm packages:

*   [chalk](https://www.npmjs.com/package/chalk) for colorized console output
*   [isomorphic-fetch](https://www.npmjs.com/package/isomorphic-fetch) for http requests (alternative: [node-fetch](https://www.npmjs.com/package/node-fetch), [axios](https://www.npmjs.com/package/axios))
*   [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken) for authentication against the admin endpoint
*   [rimraf](https://www.npmjs.com/package/rimraf) to savely delete old backups

```sh
npm i chalk isomorphic-fetch jsonwebtoken rimraf
```

Finally, let's add an npm script to run our code.

```sh
# Add to your package.json file
{
 [...],
 scripts: {
  "start": "node index"
 },
 [...]
}
```

If everything went well, your project will now have the following structure and all relevant node modules installed - we're ready to move ahead and give life to it.

```sh
/
| - config
| | - config.js
| | - util.js
| - node_modules
| - services
| | - backupContent.js
| - index.js
```

### Setup the configuration

Add the below code to your **config.js** file. The variables we define here hold API and backup configuration information and have the following purpose:

| Variable Name | Purpose |
| --- | --- |
| ghostApiUrl | The root path to your ghost admin-endpoint |
| ghostApiPaths | The paths from where to fetch backup data |
| ghostAdminKey | The integration admin key which you've noted down earlier |
| backupInterval | The interval which passes between updates in miliseconds |
| backupDirPath | The path on the backup machine where data are being stored |
| backupLifetime | For how many months to keep old updates |
| genBackupDirPath | A function to create a unique backup directory |

```javascript
const { join } = require('path');

// Configure the relevant API params
const ghostApiUrl = 'https://<your-ghost-domain>/ghost/api/v3/admin';
// If you are following along with the demo, uncomment the following: 
// const ghostApiUrl = 'https://demo.ghost.io/ghost/api/v3/content/'
// Also, make sure to append the query in the service that utilizes this parameter
const ghostApiPaths = ['posts', 'pages', 'site', 'users'];
const ghostAdminKey = '<your-admin-key>';

// Configure the backup settings
const backupInterval = 1000 /* Miliseconds */ * 60 /* Seconds */ * 60 /* Minutes */ * 24 /* Hours*/ * 1; /* Days */
const backupDirPath = '/home/pi/Backups'; 
const backupLifetime = 4; /* Months */
const genBackupDirPath = () => {
 const date = new Date();

 const year = date.getFullYear();
 const month = (date.getMonth() + 1).toString().padStart(2, 0);
 const day = date.getDate().toString().padStart(2, 0);
 const hour = date.getHours().toString().padStart(2, 0);
 const min = date.getMinutes().toString().padStart(2, 0);
 const now = `${year}_${month}_${day}-${hour}_${min}`;

 return join(backupDirPath, now);
};

module.exports = {
 ghostApiUrl,
 ghostApiPaths,
 ghostAdminKey,
 backupInterval,
 backupLifetime,
 backupDirPath,
 genBackupDirPath,
};

```

### Prepare the utility functions

There are two utilities we will need in our backup service:

*   One to handle authentication. The Ghost admin-endpoint uses Json Webtoken authentication, and the admin key you received from your integration holds the [validation signature](https://jwt.io/introduction).
*   Another one to handle the deletion of older backups. It will be called once per backup cycle.

Add the following to your **util.js** file:

```javascript
// Import the necessary functions
const { promises } = require('fs');
const { sign } = require('jsonwebtoken');
const rimraf = require('rimraf');

/**
 * @desc    Create the authorization header to authenticate against the
 *          Ghost admin-endpoint
 *
 * @param   {String} ghostAdminKey
 * 
 * @returns {Object} The headers to be appended to the http request
 */
function genAdminHeaders(ghostAdminKey) {
 // Extract the secret from the Ghost admin key
 const [id, secret] = ghostAdminKey.split(':');

 // Create a token with an empty payload and encode it.
 const token = sign({}, Buffer.from(secret, 'hex'), {
  keyid: id,
  algorithm: 'HS256',
  expiresIn: '5m',
  audience: '/v3/admin',
 });

 // Create the headers object that's added to the request
 const headers = {Authorization: `Ghost ${token}`}

 return headers;
}

/**
 * @desc    Delete backup directories that are older than the backupLifetime.
 *
 * @param   {String} dirPath The path to where backups are being stored
 * @param   {Number} backupLifetime The amount of months a backup is to be stored
 * 
 * @returns {Array} An array of paths that have been deleted 
 */
async function deleteOldDirectories(dirPath, backupLifetime) {
 const dir = await promises.opendir(dirPath);
 let deletedBackups = [];

 for await (const dirent of dir) {
  const date = new Date();
  const year = date.getFullYear();
  const month = date.getMonth() + 1;

  // For each backup entry, extract the year and month in which is was created
  const createdYear = +dirent.name.split('_')[0];
  const createdMonth = +dirent.name.split('_')[1];

  // In case backup was made this year,
  // check if createdMonth + lifetime are less than current month
  if (createdYear === year) {
   if (createdMonth - backupLifetime >= month) {
    deletedBackups.push(dirent.name)
    rimraf.sync(`${dirPath}/${dirent.name}`)
   }
  }

  // In case backup was made last year,
  // check if createdMonth + lifetime is smaller than 12
  else {
   if (createdMonth + backupLifetime <= 12) {
    deletedBackups.push(dirent.name)
    rimraf.sync(`${dirPath}/${dirent.name}`)
   }
  }
 }
 return deletedBackups;
}

module.exports = { getAdminHeaders, deleteOldDirectories };

```

### Write the service

Add the following to your **backupContent.js** file. Consider the comments inside of the code, if you're curious to understand what's going on:

```javascript
// Load relevant core package functions
const { join } = require('path');
const { mkdirSync, writeFileSync } = require('fs');
const { log } = require('console');

// Load npm modules
const fetch = require('isomorphic-fetch');
const { red, yellow, green } = require('chalk');

// Load config and utility functions
const { ghostApiUrl, ghostApiPaths, ghostAdminKey, backupDirPath, genBackupDirPath, backupLifetime } = require('../config/config');
const { genAdminHeaders, deleteOldDirectories } = require('../config/util');

// Main function for the backup service
async function runBackupContent() {
 log(green(`⏳ Time to backup your content. Timestamp: ${new Date()}`));
 try {
  // Generate the backup directory and the authorization headers
  const dir = genBackupDirPath();
  const headers = genAdminHeaders(ghostAdminKey);

  // e.g. /home/pi/Backups/2021_01_02-13_55/site.json
  mkdirSync(dir);

  // Check for old backups and clean them up
  const deleted = deleteOldDirectories(backupDirPath, backupLifetime);
  if (deleted.length > 0) {
   deleted.forEach(deletedBackup => log(yellow(`☝ Deleted backup from ${deletedBackup}`)));
  } else {
   log(green('✅ No old backups to be deleted'));
  }

  // Make a backup for all the content specified in the api paths config
  ghostApiPaths.forEach(async path => {
   // Create the relevant variables

   // The endpoint from where to receive backup data
   // e.g. https://<your-ghost-domain>/ghost/api/v3/admin/posts
   const url = `${ghostApiUrl}/${path}`; 

   // These options.headers will hold the Json Webtoken
   // e.g. 'Authorization': 'Ghost eybf12bf.8712dh.128d7g12'
   const options = { method: 'get', timeout: 1500, headers: { ...headers } };

   // The name of the corresponding backup file in json
   // e.g. posts.json
   const filePath = join(dir, path + '.json');
   const response = await fetch(url, options);

   // If the http status is unexpected, log the error down in a separate file
   if (response.status !== 200) {
    const errPath = join(dir, path + 'error.json');
    const data = await response.json();
    writeFileSync(errPath, jsonStringify(data));
    log(red(`❌ ${new Date()}: Something went wrong while trying to backup ${path}`));

   // If the http status is returned as expected, write the result to a file
   } else {
    const data = await response.json();
    writeFileSync(filePath, JSON.stringify(data));
    log(green(`✅ Wrote backup for '${path}' - endpoint into ${filePath}`));
   }
  });
 } catch (e) {
  // Do appropriate error handling based on the response code
  switch (e.code) {
   // If, for some reason, two backups are made at the same time
   case 'EEXIST':
    log(red('❌ Tried to execute a backup for the same time period twice. Cancelling ... '));
    break;
   default:
    log(red(e.message));
    break;
  }
 }
}

module.exports = runBackupContent;

```

Wrap up and run the service
---------------------------

Let's briefly recap what we've achieved

*   We created a service that makes backups of our Ghost blog on a regular basis.
*   To do so, it uses Ghost's REST admin-endpoint and Json Webtoken authentication
*   The backups are saved in a dedicated folder and backups older than 4 months are automatically deleted

Let's now import the service into our **index.js** file and run it

```javascript
// Load node modules
const { log } = require('console');
const { green } = require('chalk');

// Load the backup interval from the configuration
const { backupInterval } = require('./config/config');

// Load the service
const runBackupContent = require('./services/backupContent');

// Start the service with the given interval
log(green(`⏳ Scheduled backup job with a ${backupInterval / (1000 * 60 * 60)} - hour interval`));
runBackupContent();
setInterval(() => runBackupContent(), backupInterval);

```

Then, in your console, run the start script

```javascript
$ npm run start 
# Or run index directly
$ node index
```

Assuming everything else went well, you will now see the following console output:

![](https://q-bit.me/content/images/2021/01/01_Ghost_cms_automated_backups.jpg)

And there you have it:  A basic - but working - backup service for your blogposts.

Next steps and more features
----------------------------

I could imagine to also include the following features:

*   A service that automatically reads the latest update and sends several POST requests to the admin-endpoint ( one per path ) to restore lost pages and posts.
*   Use the [pm2 module](https://www.npmjs.com/package/pm2) to run and monitor this service.
*   Put the configuration in a database or a [.env](https://www.npmjs.com/package/dotenv) variable and build a neat monitoring api.
*   Use a [telegram bot](https://core.telegram.org/bots) to send messages to your phone when a backup was successful and/or unsuccessful.

> This post was originally published at https://q-bit.me/how-to-make-backups-of-your-ghost-blog-with-node-js/

Thank you for reading. If you enjoyed this article, let's stay in touch on Twitter 🐤 [@qbitme](https://twitter.com/qbitme)