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
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>
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',
}
}
});
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,
}
}
});
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
...
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.
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):
# 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
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
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:
docker-compose build frontend
docker-compose up -d frontend
docker exec -it frontend bash
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
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>
Rebuild and rerun:
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 thedepends_on
list in yourdocker-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 (2)
Thank you! Great article. After hours on this problem, your solution finally worked!
Keep in mind to stringify the env vars, that are strings, in the vite.config.js
VITE_SERVER_HOST: JSON.stringify(process.env.VITE_SERVER_HOST),
otherwise the build process could throw this kind of an error:At least it did in my case.
Thanks again!
Thanks for sharing @borisuu!
I faced the same problem and found a similar solution (arg + env), but I removed the "define" option in the vite config, and it works:
I'm using vite@4.2.2.