Serving a vue-cli Production Build on a Sub-Path with Nginx

Following two previous blog posts on using vue-cli with Docker (here and here) this post now covers on how to do a production build of a vue-cli based app and how to deliver it via an nginx web server, especially on a (non-root) sub-path. So this post is mostly about the difficulties regarding a sub-path configuration in vue-cli and in nginx. You can probably omit much of if it if you serve your app on example.com/ or on subdomain.example.com/.

But if you are looking for something like example.com/path/to/app/ ... then this is the right post for you.

Up front ... the most simple way to test/locally serve your bundled app is explained here.

Differences Between Development and a Production Build?

Since a vue-cli app is a single page client side only application without an (app) server (like e.g. express in the backend), it can be regarded as a "static" Javascript app/HTML website which needs to be delivered through a (web) server (like nginx) on the initial client request.

During development with yarn serve/vue-cli-service serve webpack's dev-server handles (hot) bundling of the code on every code change and serves each hot build immediately to your browser request.

For production with yarn build/vue-cli-service build and app as the default build target your application source code gets bundled into one folder called dist. As with static HTML websites this folder needs to be served by a web server such as nginx.

If you are developing your frontend app separately from your backend then your frontend is essentially a purely static app. You can deploy the built content in the dist directory to any static file server, but make sure to set the correct baseUrl (source).

Requirements in vue-cli for a Sub-Path Deployment

In the root directory of the application the vue.config.js needs a setting for the baseUrl environment variable if it is served on a non-root path (example.com/path/to/app/):

module.exports = {
  baseUrl: '/path/to/app',
};

# or passed in via docker build arg and env var (see below dockerfile)
#  baseUrl: process.env.APP_BASEPATH,

This setting is also needed in your router config if you use vue router:

export default new Router({
  base: process.env.BASE_URL,
});

Also in your index.html for links to static assets you should now use:

<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<script type="text/javascript" src="<%= BASE_URL %>someScript.js"></script>

If using history mode for vue router (which removes the hash mode) it is also advisable to have a catch-all route in your app and in your nginx location directive:

Since our app is a single page client side app, without a proper server configuration, the users will get a 404 error if they access http://oursite.com/user/id directly in their browser (source).

So add a catch-all fallback route to your nginx location directive: If the URL doesn't match any static assets, it should serve the same index.html:

location / {
  try_files $uri $uri/ /index.html;
}

Your server will no longer report 404 errors as all not-found paths now serve up your index.html file. To get around the issue, you should implement a catch-all route within your Vue app to show a 404 page (source).

So also add this to your vue router:

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '*', component: NotFoundComponent }
  ]
});

Nginx Settings for Serving from a Sub-Path

This is a basic example for an app.conf (based on nginx's current default.conf) and inspired by the settings proposed here for reactjs:

server {
    listen 80;

    server_name example.com;

    location ^~ /path/to/app {
        # https://stackoverflow.com/questions/10631933/nginx-static-file-serving-confusion-with-root-alias
        # root /usr/share/nginx/html/dist/;
        alias /usr/share/nginx/html/dist/;

        expires -1;
        add_header Pragma "no-cache";
        add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0";
      
        try_files $uri $uri/ /index.html = 404;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

Using a Docker Multi-Stage Build for vue-cli Build and Nginx

If using docker you and if you want to combine yarn build and and nginx in one dockerfile then you could follow along with the pattern proposed here: A multi-stage build where your app gets built in the first step and is then copied over in the next step.

For a sub-path configuration (already available at docker image build-time) pass in APP_BASEPATH as build argument via docker-compose args or via docker build --build-arg APP_BASEPATH="/path/to/app" ... (so this is passed in at image build-time and then set as ENV for containers at container runtime):

FROM node:10.9-slim as build-stage

ARG APP_BASEPATH
ENV APP_BASEPATH=${APP_BASEPATH}

# if using git clone instead of COPY
# RUN apt-get -y update \
#   && apt-get install -y git

RUN yarn global add @vue/cli -g

WORKDIR /usr/src/app
COPY app /usr/src/app

RUN yarn install

# yarn build (now in production mode) is a RUN command (image build-time) and no longer a CMD (container runtime)!
# This is why you also need APP_BASEPATH declared as ARG
RUN yarn build


FROM nginx:1.15.3

COPY --from=build-stage /usr/src/app/dist /usr/share/nginx/html/dist

# keep /etc/nginx/nginx.conf with include statement for all /etc/nginx/conf.d/*.conf
# but remove /etc/nginx/conf.d/default.conf for approbriate sub-path settings
RUN rm /etc/nginx/conf.d/default.conf
COPY  app.conf /etc/nginx/conf.d/app.conf

EXPOSE 80

WORKDIR /etc/nginx

CMD ["nginx", "-g", "daemon off;"]

During development with CMD yarn serve a simple ENV would be sufficient because with CMD it only affects container runtime but with RUN yarn build during the production build APP_BASEPATH already needs to be defined as ARG because RUN yarn build is executed during image build-time (and not during container runtime as it is the case for CMD).

Further Information

vue-cli docs on deployment:
https://cli.vuejs.org/guide/deployment.html#general-guidelines
https://cli.vuejs.org/guide/deployment.html#previewing-locally
https://cli.vuejs.org/config/#baseurl

https://router.vuejs.org/api/#base
https://router.vuejs.org/guide/essentials/history-mode.html#nginx
https://router.vuejs.org/guide/essentials/history-mode.html#caveat

Dockerizing & serving SPA's:
https://vuejs.org/v2/cookbook/dockerize-vuejs-app.html
https://mherman.org/blog/dockerizing-a-react-app/

Docker multi-stage builds:
https://docs.docker.com/develop/develop-images/multistage-build/

Dev Server (only during development):
https://cli.vuejs.org/config/#devserver

Previous blog posts on vue-cli and docker:
https://daten-und-bass.io/blog/getting-started-with-vue-cli-on-docker/
https://daten-und-bass.io/blog/enabling-hot-reloading-with-vuejs-and-vue-cli-in-docker/