exanubes

Dockerize a fullstack application

Dockerizing a Postgres, Nestjs & React application with separate development and test databases. Run with a single command thanks to docker-compose.

In this approach the only public facing part of the application will be the frontend which will have access to the backend thanks to a shared network and likewise backend will have access to the database that same way.

You can checkout the finished code on github

Frontend #

First off, frontend needs a proxy server that will either serve the static files if it's a page request or forward it to the backend REST endpoint.

javascript
Double click to copy
1const express = require("express")
2const { createProxyMiddleware } = require("http-proxy-middleware")
3const app = express()
4const port = 3001
5const options = {
6 /* we will use this environmental variable to
7 feed the backend server url via a service name */
8 target: process.env.REST_API_URL,
9
10 // changes the origin of the host header to the target URL
11 changeOrigin: true,
12
13 pathRewrite: {
14 /* client will send all backend requests to /api/path/to/endpoint
15 this will remove the /api prefix when forwarding the request to the server */
16 "^/api": "",
17 },
18}
19
20// adding proxy middleware for /api requests
21app.use("/api", createProxyMiddleware(options))
22
23// standard static file serve
24app.use(express.static("public"))
25
26app.listen(port, () => {
27 console.log(`Example app listening at http://localhost:${port}`)
28})

Naturally, to run this in docker we need a Dockerfile that will download a Node.js image, install all dependencies, build the site and startup our simple proxy server to serve it.

Dockerfile
Double click to copy
1FROM node:14.15.0
2
3COPY . .
4
5RUN npm install && npm run build
6
7EXPOSE 3001
8
9CMD node server/main.js

Use a .dockerignore file to avoid copying unnecessary folders into the container. Here I recommend putting .cache, public and node_modules to avoid long build times as well as gatsby build errors.

You can build and run this image with

bash
Double click to copy
1docker build -t my-frontend-app .
2docker run -e REST_API_URL=http://localhost:3000 -p 3001:3001 my-frontend-app

Database #

Setting up a database with docker-compose is as easy as it can be. Define the image you want to use, expose a port, add database variables and define a volume for persistent data.

docker-compose
Double click to copy
1version: '3.9'
2services:
3 exanubes-database:
4 image: postgres:12-alpine
5 container_name: exanubes-database
6 expose:
7 - "5432"
8 environment:
9 - POSTGRES_PASSWORD=exanubes
10 - POSTGRES_USER=exanubes
11 - POSTGRES_DB=exanubes-prod
12 volumes:
13 - ./postgres-data:/var/lib/postgesql/data

Backend #

Backend is a little bit more complicated as it needs to connect to different databases depending on the environment it's in, so we're gonna use some environment variables to configure the connection.

Sequelize

Aside from that, sequelize-cli which is responsible for performing migrations on the database, has to be configured as well. So in db/config.json add:

json
Double click to copy
1{
2 "production": {
3 "username": "exanubes",
4 "password": "exanubes",
5 "database": "exanubes-prod",
6 "host": "database",
7 "dialect": "postgres"
8 }
9}

Each key in this config relates to the NODE_ENV environment variable's value. It's completely arbitrary and can be set to anything.

Dockerfile

One caveat in backend's Dockerfile is that we have to run migrations before starting the server. Also making sure the NODE_ENV is what is expected it to be, I set it explicitly when running the commands. Rest is pretty much the same as in the frontend image

Dockerfile
Double click to copy
1FROM node:14.15.0-alpine
2
3COPY . .
4
5RUN npm install && npm run build
6
7EXPOSE 3000
8CMD NODE_ENV='production' npm run migrate && NODE_ENV='production' node dist/main.js

It's also worth creating a .dockerignore file to prevent copying node_modules, .env files and dist folder

Docker Compose #

To make everything easier on us when deploying this application, we'll add backend and frontend services to docker-compose.yml

docker-compose
Double click to copy
1 exanubes-backend:
2 restart: always
3 build: ./backend
4 container_name: exanubes-backend
5 links:
6 - "exanubes-database:database"
7 depends_on:
8 - exanubes-database
9 environment:
10 - DB_USER=exanubes
11 - DB_PASSWORD=exanubes
12 - DB_NAME=exanubes-prod
13 - DB_HOST=database

First of all, docker-compose needs to know where's the Docker image it's supposed to build. In the database service we used an existing postgres image, here we point to the backend directory and from there docker-compose will build the Dockerfile that's in there. Link and alias the database service, tell docker that we rely on that service so it will spin that up first and finally pass in the environment variables so we can connect to the database. These should be the same as in db/config.json.

docker-compose
Double click to copy
1 exanubes-frontend:
2 restart: always
3 build: ./frontend
4 container_name: exanubes-frontend
5 links:
6 - "exanubes-backend:backend"
7 ports:
8 - "3001:3001"
9 environment:
10 - REST_API_URL=http://backend:3000

Very similar to the backend service, we set the restart option, point Docker to the image definition but this time link and alias the backend service. Now, because frontend has to be accessible from outside the container, we gotta map the container port to the host port. Last but not least we pass the backend url to the proxy server using the backend alias.

Test & Development #

Adding test and development database is very similar to production database.

docker-compose
Double click to copy
1 dev-database:
2 image: postgres:12-alpine
3 container_name: exanubes-dev-database
4 profiles: ["dev"]
5 ports:
6 - "5431:5432"
7 environment:
8 - POSTGRES_PASSWORD=exanubes
9 - POSTGRES_USER=exanubes
10 - POSTGRES_DB=exanubes-db-dev
11 volumes:
12 - ./pg-data:/var/lib/postgresql/data

Each of the databases definitely needs a different db name in POSTGRES_DB. Image should stay the same as we do not want a different version of postgres depending on environment. Define a separate mapping for the dev database volume. I also like to define profiles for non production services. This way I make sure that when I run this service all other dev services will be run as well but also that this service will not be run when I want run the production app with docker-compose up.

docker-compose
Double click to copy
1 test-database:
2 image: postgres:12-alpine
3 container_name: exanubes-test-database
4 profiles: ["test"]
5 ports:
6 - "5433:5432"
7 environment:
8 - POSTGRES_PASSWORD=exanubes
9 - POSTGRES_USER=exanubes
10 - POSTGRES_DB=exanubes-db-test

Pretty much the same as dev but this time with a test profile and without a volume as we don't need persistent data with automatic tests. Last but not least, both of these databases need to map container port to host machine port so we're able to connect to them from localhost.

To get all this to work I also added development and test configs for the sequelize-cli in db/config.json

json
Double click to copy
1 {
2 "development": {
3 "username": "exanubes",
4 "password": "exanubes",
5 "database": "exanubes-db-dev",
6 "host": "localhost",
7 "dialect": "postgres",
8 "port": "5431"
9 },
10 "test": {
11 "username": "exanubes",
12 "password": "exanubes",
13 "database": "exanubes-db-test",
14 "host": "localhost",
15 "dialect": "postgres",
16 "port": "5433"
17 }
18 }

You can checkout the e2e tests by running npm run test:e2e in the backend directory. This should build the test-database service, run tests and tear it down before exiting.

In a future article I'll cover how to deploy a dockerized application on Elastic Container Service

Thanks!

A verification email has been sent to

Keep in Touch

Join other developers in Exanubes Newsletter

© 2023