Skip to main content

Serving Static Files with Hapi and Docker

Central, Hong Kong

I need to build a web server for static files generated by Gatsby.js. So far I was using Express.js to do this. But there seems to be a new library that is gaining popularity among my coworkers called Hapi.js. Let's see if we can make this work for us.

Installing hapi

  1. Create a new directory gatsby-static, and from there:
cd gatsby-static
npm init
npm install @hapi/hapi

This will install the latest version of hapi as a dependency in your package.json.

Creating a Server

A very basic hapi server looks like the following:

'use strict';

const Hapi = require('@hapi/hapi');

const init = async () => {

const server = Hapi.server({
port: 3000,
host: '0.0.0.0'
});

await server.start();
console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', (err) => {

console.log(err);
process.exit(1);
});

init();

Adding Routes

After you get the server up and running, its time to add a route that will display "Hello World!" in your browser.

'use strict';

const Hapi = require('@hapi/hapi');

const init = async () => {

const server = Hapi.server({
port: 3000,
host: '0.0.0.0'
});

server.route({
method: 'GET',
path: '/',
handler: (request, h) => {

return 'Hello World!';
}
});

await server.start();
console.log('Server running on %s', server.info.uri);
};

process.on('unhandledRejection', (err) => {

console.log(err);
process.exit(1);
});

init();

Save the above as app.js and start the server with the command node app.js. Now you'll find that if you visit http://localhost:3000 in your browser, you'll see the text Hello, World!.

Serving Static Content

There is a hapi plugin called inert that adds this functionality to hapi through the use of additional handlers. First you need to install and add inert as a dependency to your project:

npm install @hapi/inert

File Handler

The inert plugin provides new handler methods for serving static files and directories, as well as adding a h.file() method to the toolkit, which can respond with file based resources. To simplify things, especially if you have multiple routes that respond with files, you can configure a base path in your server and only pass relative paths to h.file():

'use strict';

const Path = require('path');
const Hapi = require('@hapi/hapi');
const Path = require('path');

const start = async () => {

const server = Hapi.server({
routes: {
files: {
relativeTo: Path.join(__dirname, 'public')
}
}
});

await server.register(require('@hapi/inert'));

server.route({
method: 'GET',
path: '/picture.jpg',
handler: function (request, h) {

return h.file('picture.jpg');
}
});

await server.start();

console.log('Server running at:', server.info.uri);
};

start();

Note that you need to npm install path into your project to work with the Gatsby.js public directory.

Directory Handler

In addition to the file handler, inert also adds a directory handler that allows you to specify one route to serve multiple files. In order to use it, you must specify a route path with a parameter. The name of the parameter does not matter, however. You can use the asterisk extension on the parameter to restrict file depth as well. The most basic usage of the directory handler looks like:

server.route({
method: 'GET',
path: '/{param*}',
handler: {
directory: {
path: 'directory-path-here'
}
}
});

The above route will respond to any request by looking for a matching filename in the directory-path-here directory. Note that a request to / in this configuration will reply with an HTTP 403 response. You can fix this by adding an index file. By default hapi will search in the directory for a file called index.html:

server.route({
method: 'GET',
path: '/{param*}',
handler: {
directory: {
path: 'directory-path-here',
index: ['index.html']
}
}
});

A request to / will now first try to load /index.html. When there is no index file available, inert can display the contents of the directory as a listing page. You can enable that by setting the listing property to true like so:

server.route({
method: 'GET',
path: '/{param*}',
handler: {
directory: {
path: 'directory-path-here',
listing: true
}
}
});

Now a request to / will reply with HTML showing the contents of the directory. When using the directory handler with listing enabled, by default hidden files will not be shown in the listing. That can be changed by setting the showHidden option to true. Like the file handler, the directory handler also has a lookupCompressed option to serve precompressed files when possible. You can also set a defaultExtension that will be appended to requests if the original path is not found. This means that a request for /bacon will also try the file /bacon.html.

Gatsby Server

One common case for serving static content is setting up a file server. The following example shows how to setup a basic file serve in hapi:

const Path = require('path');
const Hapi = require('@hapi/hapi');
const Inert = require('@hapi/inert');

const init = async () => {

const server = new Hapi.Server({
port: 7777,
routes: {
files: {
relativeTo: Path.join(__dirname, 'public')
}
}
});

await server.register(Inert);

server.route({
method: 'GET',
path: '/{param*}',
handler: {
directory: {
path: '.',
redirectToSlash: true
}
}
});

await server.start();

console.log('Server running at:', server.info.uri);
};

init();

After your server is configured, you then register the inert plugin. This will allow you to have access to the directory handler, which will enable you to server your files. In the directory handler, you configure path, which is required, to look in the entire Gatsby public directory which you specified in the relativeTo option. The second option is the redirectToSlash option. By setting this to true, you tell the server to redirect requests without trailing slashes to the same path with those with the trailing slash.

Docker it!

I now have a directory gatsby-static that contains the node_modules folder and 3 files - the app.js file (see above) and packages.json (+ package-lock.json):

{
"name": "hapi-container",
"version": "1.0.0",
"description": "hapi static server",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Mike Polinowski",
"license": "MIT",
"dependencies": {
"@hapi/hapi": "^20.0.0",
"@hapi/inert": "^6.0.2",
"path": "^0.12.7"
}
}

Note that I added a start script here. This way I am able to start my app with npm start instead of having to type node app.js.

I will now copy those - but not the public folder with my static files! Also the node_modules does not have to be included inside the image - into a subdirectory called container, create an empty directory called public inside it and create a Dockerfile in the root directory:

  • my-static-files
  • container
    • public
    • app.js
    • package.json
    • package-lock.json
  • Dockerfile

The Dockerfile should look like this:

FROM        node:latest
LABEL maintainer="m.polinowski@instar.com"
ENV NODE_ENV=production
ENV PORT=7777
COPY ./container /wiki_en_ssr
WORKDIR /wiki_en_ssr
RUN npm install
EXPOSE 7777
ENTRYPOINT ["npm", "start"]

This will wrap our hapi server inside a docker image once we run the docker build command:

docker build  -t wiki-instar-com -f /path/to/a/Dockerfile .

Sending build context to Docker daemon 1.563GB
Step 1/9 : FROM node:latest
---> 784e696f5060
Step 2/9 : LABEL maintainer="m.polinowski@instar.com"
---> Running in 6fd6a2ee6aa1
Removing intermediate container 6fd6a2ee6aa1
---> aae0a3c6671b
Step 3/9 : ENV NODE_ENV=production
---> Running in 4b2ffdce8455
Removing intermediate container 4b2ffdce8455
---> 0ff0a5d4528c
Step 4/9 : ENV PORT=7777
---> Running in c4b56a96175d
Removing intermediate container c4b56a96175d
---> 0d7e9b6d66a3
Step 5/9 : COPY ./container /wiki_en_ssr
---> f8fad2e28ae9
Step 6/9 : WORKDIR /wiki_en_ssr
---> Running in 88f9a237ecf1
Removing intermediate container 88f9a237ecf1
---> 394ad4d346e9
Step 7/9 : RUN npm install
---> Running in 96161550ef65
npm WARN hapi-container@1.0.0 No repository field.

added 37 packages from 7 contributors and audited 37 packages in 6.389s
found 0 vulnerabilities

Removing intermediate container 96161550ef65
---> 78a03539837f
Step 8/9 : EXPOSE 7777
---> Running in 1dc2cdaa2eb4
Removing intermediate container 1dc2cdaa2eb4
---> f2c4e7f61583
Step 9/9 : ENTRYPOINT ["npm", "start"]
---> Running in bec8b8ed7815
Removing intermediate container bec8b8ed7815
---> 418d52e68b85
Successfully built 418d52e68b85
Successfully tagged wiki-instar-com:latest
docker build  -t wiki-instar-de -f /path/to/a/Dockerfile .

docker build -t wiki-instar-fr -f /path/to/a/Dockerfile .

You can now start a container from this image and point it to your folder with static content:

docker run -p 8080:7777 -v /path/to/my-static-files:/wiki_en_ssr/public wiki-instar-com

> hapi-container@1.0.0 start /wiki_en_ssr
> node app.js

Server running at: http://localhost:7777

Note that the path to your static files as well as the public directory inside your container has to be absolute. For example docker run -p 8080:7777 -v /opt/hapi-container-en/app:/wiki_en_ssr/public wiki-instar-com.

To run your container detached from your console use:

docker run -d -p 8080:7777 -v /opt/hapi-container-en/app:/wiki_en_ssr/public --network=wikinet --name wiki_en wiki-instar-com
c6a398309919201f63b3bff32436f4a63a35bc17fd1b5dd43b43d0e5ed9ce9e5

docker ps
CONTAINER ID IMAGE COMMAND PORTS NAMES
c6a398309919 wiki-instar-com "npm start" 0.0.0.0:8080->7777/tcp wiki_en