DEV Community

Cover image for Setup Vite + Vue.js + Docker
borisuu
borisuu

Posted on

Setup Vite + Vue.js + Docker

Introduction

Two things annoyed me on my last project. First, I had to use VITE_ prefixes on my environment variables in my .env file and second, I had to repeat myself on occasion when doing so.

I also wanted to avoid having to copy my main .env file to the frontend directory when developing locally or use the env_file option in the docker-compose.yaml configuration.

All this led me down a rabbit hole, where I finally came up with a solution. Come with me on this crazy ride and let's discover what I had to do, in order to achieve this...

TL;DR;

The reason I couldn't build is that the env_file and environment options in the docker-compose.yaml provide environment variables only in the finished container, not for the intermediate ones. Using ARG and ENV directives in the Dockerfile fixes that.

This is most relevant for multi-stage builds, or when your builds don't use sources you've already built locally.

# Dockerfile
...
# accept the environment variables from outside
ARG VITE_BACKEND_URL
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
...

# docker-compose.yaml
...
services:
  frontend:
  build:
    context: frontend/
    arguments:
      # inject the value provided by your root .env file, or an environment variable
      - VITE_BACKEND_URL=${VITE_BACKEND_URL}
  backend:
...

# vite.config.js
...
import vue from '@vitejs/plugin-vue';

export default defineConfig(() => {
  return {
    plugins: [vue()],
    define: {
      VITE_BACKEND_URL: process.env.VITE_BACKEND_URL,
    }
  }
});
...

# .env

VITE_BACKEND_URL=https://backend.test
Enter fullscreen mode Exit fullscreen mode

The Problem

The main problem originates in Vite, which uses static variable replacement in its config when building for production (also in development, although it's a bit more lenient).

Production replacement of env variables in Vite

Basically whenever you do something like this in Vue.js with Vite:

<script>
  const backendUrl = import.meta.env.VITE_BACKEND_URL;

  axios.get(`${backendUrl}/api/resources`).then(...);
  ...
</script>
Enter fullscreen mode Exit fullscreen mode

You'll encounter no errors when building but will be greeted by a nice call to undefined/api/resources, when you try to use your backend.

Mostly you'll have a .env lying around, which will be included in development mode, thus postponing the pain until you decide to build for production.

The failed solution

After a couple of months of development, it came time to start building for production and testing in production-ready environments. To my surprise nothing worked in the frontend - all the services needed to operate were undefined. After some googling around I came across the issue subtly described on the Vite homepage Vite config.

At first, I thought the solution would be to just use the define value in the vite.config.js:

import vue from '@vitejs/plugin-vue';
import { defineConfig } from "vite";

export default defineConfig(() => {
    return {
        plugins: [vue()],
        define: {
            VITE_BACKEND_URL: 'http://backend',
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

which works, because it's a static value (RHS of the definition). Optimistically, I then tried to replace it with the value from the .env file:

import vue from '@vitejs/plugin-vue';
import { defineConfig, loadEnv } from "vite";

export default defineConfig(() => {
    // The first argument is `mode` which is irrelevant for us.
    const env = loadEnv('', process.cwd());
    return {
        plugins: [vue()],
        define: {
            VITE_BACKEND_URL: env.VITE_BACKEND_URL,
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

This was, however, unsatisfactory, as I'd have to include an .env file of some sort.

Then I tried using the process.env object to get the values without loading any files. Sadly the
environment variables were always empty, although I was using the env_file option in my docker-compose.yaml config. The reason behind that is that environment variables are passed from Docker only to the finished container, so at build time we don't have access to them. That's when I started digging even deeper, trying to fix this.

The real solution

It turned out that the env_file option exposes the environment variables from my main .env, but only for the finished container. I wanted to use the variables during the build stage in an intermediate container. In order to make that work, we'd have to load the variables as ARG in the Dockerfile.

For example:

...
ARG VITE_BACKEND_URL
...
Enter fullscreen mode Exit fullscreen mode
services:
  frontend:
    build:
      context: frontend/
    args:
      - VITE_BACKEND_URL=${VITE_BACKEND_URL}
Enter fullscreen mode Exit fullscreen mode

The last line in the compose file allows us to use the variable defined in our root .env file and set the argument VITE_BACKEND_URL (used in the Dockerfile).

Now we're defining the value of VITE_BACKEND_URL in our .env file, passing it through docker-compose.yaml to frontend/Dockerfile and finally into the environment when Vite is building our app.

Then we can directly use process.env to load our variables.

import vue from '@vitejs/plugin-vue';

export default defineConfig(() => {
  return {
    plugins: [vue()],
    define: {
      VITE_BACKEND_URL: process.env.VITE_BACKEND_URL,
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Putting it all together

Assuming the following project structure (displaying only relevant information):

app/
├─ backend/
│  ├─ Dockerfile
├─ frontend/
│  ├─ src/
│  ├─ Dockerfile
│  ├─ vite.config.js
├─ docker-compose.yaml
Enter fullscreen mode Exit fullscreen mode

Now that we know how to avoid the pitfalls with environment variables in Vite during build time.

Let's define a Dockerfile for our frontend (assuming you've already initialized your frontend project):

# it's a good idea to pin this, but for demo purposes we'll leave it as is
FROM node:latest as builder

# automatically creates the dir and sets it as the current working dir
WORKDIR /usr/src/app
# this will allow us to run vite and other tools directly
ENV PATH /usr/src/node_modules/.bin:$PATH

# inject all environment vars we'll need
ARG VITE_BACKEND_URL
# expose the variable to the finished cotainer
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL

COPY package.json ./

RUN npm install

# use a more specific COPY, as this will include files like `Dockerfile`, we don't really need inside our containers.
COPY . ./

FROM builder as dev
CMD ["npm", "run", "dev"]

FROM builder as prod-builder
RUN npm run build

# it's a good idea to pin this, but for demo purposes we'll leave it as is
FROM nginx:latest as prod

COPY --from=prod-builder /usr/src/app/dist /usr/share/nginx/html

CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

What's happening here:

First of all we define 4 different containers: builder, dev, prod-builder and prod. We'll use them to switch between development and production.

The first container builder is tasked with installing the dependencies and preparing the environment variables.

We've already discussed the env variables in length. You can omit the ENV ... call if you don't need the variable in the finished product.

NOTE: The call to ENV will only propagate to derived container images. When we define an environment variable using the ARG ... ENV ... pattern, we'll only be able to use it in containers deriving from the one where these were defined. In our example the nginx container will not have access to the variables, and thats fine.

Next let's define our docker-compose.yaml

version: 3
services:
  frontend:
    build:
      context: frontend
      target: prod
      args:
        - VITE_BACKEND_URL=${VITE_BACKEND_URL}
    container_name: frontend
    depends_on:
        - backend
    networks:
      - api
  backend:
    build:
      context: backend
    networks:
      - api
networks:
  api:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Your env file in the root of the project

VITE_BACKEND_URL=https://backend.test
Enter fullscreen mode Exit fullscreen mode

We've already defined the vite.config.js file in the previous sections.

If you want to change the target of the frontend build, you can either edit the main docker-compose.yaml or add a docker-compose.override.yaml file and do it from there.

Let's build:

docker-compose build frontend
docker-compose up -d frontend
docker exec -it frontend bash
Enter fullscreen mode Exit fullscreen mode

Let's examine the environment using printenv:

root@a67cabd6fb54:/ printenv

HOSTNAME=a67cabd6fb54
PWD=/
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NGINX_VERSION=1.23.2
_=/usr/bin/printenv
Enter fullscreen mode Exit fullscreen mode

So the VITE_BACKEND_URL is not in our environment as expected, we can console log it in one of our .vue files to confirm it was indeed compiled into our source files:

# App.vue

<script>
  console.log(import.meta.env);
</script>
Enter fullscreen mode Exit fullscreen mode

Rebuild and rerun:

docker-compose build frontend
docker-compose up -d frontend
Enter fullscreen mode Exit fullscreen mode

NOTE: You could run docker-compose up -d --build frontend, but that will actually build all services listed in the depends_on list in your docker-compose.yaml config file.

When we visit our homepage we'll see the console output in the dev tools.

Summary

We've learned how to inject environment variables during build time of our Vite frontend container. Since the dev container derives from the root builder, the environment variables will be still available when the CMD ["npm", "run", "dev"] line is run. We will NOT however have dynamic values, meaning changes to the .env file won't be reflected immediately. You can achieve this with the env_file option in the docker-compose.yaml configuration.
We also have to recognise that this is but a single way to solve a problem. I'm sure there are many other ways to solve this issue.

Top comments (0)