Serving Static Files with Hapi and Docker
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
- 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.jspublic
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 typenode 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