johanneskueber.com

Running an SPA and an API in the same Docker Container

Hosting a static web page was never a problem. A simple Apache or nginx server and a local directory on the server was always enough. With cloud environment these tools are still available. And it is simple enough to mount a directory into an nginx container. However, you still need to spin up a container just for the purpose of hosting a static page. Using a JAMstack application, you also need an API container to connect to. In order to reduce the number of containers and the overhead, I will show how we can integrate the the server for the static page into our API server. This way, we will have everything included in a single container. This is all achieved by using dockers multi-stage builds.

The Dockerfile

The container will be built using a Dockerfile which has three stages. First we build the SPA application, I have chosen nuxt. Then we compile our API server, which is built in golang. Finally we stitch together the two using a single output container based on the very basic scratch container.

The basic outline of the Dockerfile

  • Stage 1: Build the SPA for production
  • Stage 2: Build the server
  • Stage 3: Copy the SPA and the web server into the final docker container

Obviously, you can mix and match the technology stack. Stage 1 can be any SPA oder static site generator you want. For example [Angular]https://angular.io/), Gridsome, or React. The same hold true for the API part. Use whatever architecture you want. You just have to make sure that it servers the SPA as a fallback and for the root of the web address.

#
# First stage: build nuxt app
#
FROM node:16-alpine as spa

COPY ui/package.json ui/package-lock.json ./

RUN npm set progress=false && npm config set depth 0 && npm cache clean --force

## Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN npm i && mkdir /nuxt-app && cp -R ./node_modules ./nuxt-app

WORKDIR /nuxt-app

COPY ui/ .

## Build the nuxt app in production mode and store the artifacts in dist folder
RUN npm install
RUN npm run generate

#
# Second stage: build the executable.
#
FROM golang:1.15-alpine AS api

# Create the user and group files that will be used in the running container to
# run the process as an unprivileged user.
RUN mkdir /user && \
    echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd && \
    echo 'nobody:x:65534:' > /user/group

# Install the Certificate-Authority certificates for the app to be able to make
# calls to HTTPS endpoints.
# Git is required for fetching the dependencies.
RUN apk add --no-cache ca-certificates git gcc musl-dev

# Set the working directory outside $GOPATH to enable the support for modules.
WORKDIR /src

# Fetch dependencies first; they are less susceptible to change on every build
# and will therefore be cached for speeding up the next build
COPY ./go.mod ./go.sum ./
RUN go mod download

# Import the code from the context.
COPY ./ ./

# Build the executable to `/app`. Mark the build as statically linked.
# RUN CGO_ENABLED=1 go build -installsuffix 'static' -o /app .
RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o /app .

#
# Final stage: the running container.
#
FROM scratch AS final

# Import the user and group files from the golang stage.
COPY --from=api /user/group /user/passwd /etc/
# Import the Certificate-Authority certificates for enabling HTTPS.
COPY --from=api /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Import the compiled executable from the golang stage.
COPY --from=api /app /app
# Import the ui from the spa stage
COPY --from=spa /nuxt-app/dist /usr/share/spa

# Perform any further action as an unprivileged user.
USER nobody:nobody

EXPOSE 8080

# Run the compiled binary.
ENTRYPOINT ["/app"]

Remarks for the SPA build stage

The beauty of this approach: no changes are required for the SPA.

The only thing that comes to mind is not really a requirement but it reliefes some stress from the development part of your application: establish a small dev-proxy to forward SPA http requests meant for the api to your local development intance of the API server.

In nuxt, this is done using the @nuxt/router package:

  ...
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    '@nuxt/http',
    '@nuxtjs/proxy'
  ],

  proxy: {
    '/minio/': 'http://127.0.0.1:8000'
  },  
  ...

All SPA frameworks like [Angular]https://angular.io/) or React have the option to implement a small routing roule to ease the development locally. For production routing will be covered by our API server actually providiing our endpoints.

Points to look out for in the API server

I think this is the most important, but also most obvious part of the changes required: the api server needs to serve the SPA/static html as its root. For Golang this is achieved with a single SPA handler registered at the root level of a mux router:

router.PathPrefix("/").Handler(spaHandler{staticPath: "/usr/share/spa", indexPath: "index.html"})

The code shown above tells our api server to serve the static page base file "index.html" from the indicated folder. This is the same folder we copied the genreated SPA files in from our stage 1 build in the Dockerfile


stat /posts/spa_and_server_in_one_dockerfile

2021-04-28: Initial publication of the article