Setup Vite + Vue.js + Docker
Tags: docker, vue, vite, tutorial
Published: 2023-10-25 08:15:57

## 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
```

## 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](https://vitejs.dev/guide/env-and-mode.html#production-replacement)

Basically whenever you do something like this in `Vue.js` with `Vite`:
```vue
<script>
  const backendUrl = import.meta.env.VITE_BACKEND_URL;

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

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](https://vitejs.dev/guide/env-and-mode.html#production-replacement). 

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

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

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

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:

```javascript
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,
		}
	}
});
```

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:
```Dockerfile
...
ARG VITE_BACKEND_URL
...
```

```yaml
services:
  frontend:
    build:
      context: frontend/
    args:
      - VITE_BACKEND_URL=${VITE_BACKEND_URL}
```

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.

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

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

## 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
```

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):

```Dockerfile
# 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;"]
```

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`

```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
```

Your env file in the root of the project

```env
VITE_BACKEND_URL=https://backend.test
```

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:

```bash
docker-compose build frontend
docker-compose up -d frontend
docker exec -it frontend bash
```

Let's examine the environment using `printenv`:

```bash
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
```

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:

```vue
# App.vue

<script>
  console.log(import.meta.env);
</script>
```

Rebuild and rerun:

```bash
docker-compose build frontend
docker-compose up -d frontend
```

>**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.