Skip to content

Chapter 8 - Manage the environment variables with dotenv

Applications often run in different environments. Depending on the environment, different configuration settings should be used. We need to have a way to easily update the configuration without having to change the codebase. A common way to do so is to use environment variables. These are variables that are stored and loading from your operating system.

dotenv offers the possibility to have a .env file (pronounced "dot env") from where it will load the defined variables as environment variables in your application.

In this chapter, you will manage environment variables in your frontend and backend services. You'll update your code so it uses environmental variables with the help of dotenv to load these from .env files.

This will allow to easily update the listening port of the backend or the color of the stroke and the backend URL of the frontend and be conform to The Twelve-Factor App methodology.

Steps

Backend steps

Info

All the following steps will be executed in the backend directory.

In a new terminal, you can switch to the backend directory with the following command.

In a terminal, execute the following command(s).
cd backend

At the moment, the port of the backend service is fixed to 4000. In some cases, you might want to change this port because another process already uses that port.

NestJS offers a package to load environment variables.

It is standard practice to throw an exception during application startup if required environment variables haven't been provided or if they don't meet certain validation rules. With Joi, you define an object schema and validate JavaScript objects against it.

For example, a port has to be a number. The validation schema can check if the port is a valid number or not, and warn the user if it's not. By default, all environment variables are treated as strings.

Install NestJS Configuration module and Joi

Install NestJS Configuration module and Joi with the following command.

In a terminal, execute the following command(s).
npm install --save @nestjs/config joi

Create a custom NestJS Config module

The following code could be put directly in the src/app.module.ts file. However, it is cleaner to keep things separated and create a new config package with its own module that can then be loaded by the main file.

backend/src/config/config.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule as NestConfigModule } from "@nestjs/config";
import * as Joi from "joi";

@Module({
	imports: [
		NestConfigModule.forRoot({
			validationSchema: Joi.object({
				// (1)!
				PORT: Joi.number().default(4000), // (2)!
			}),
			validationOptions: {
				allowUnknown: true, // (3)!
				abortEarly: false, // (4)!
			},
		}),
	],
	exports: [NestConfigModule], // (5)!
})
export class ConfigModule {}
  1. Define the validation schema with Joi.
  2. The assure with the validation that the PORT is a number. The default value of this environment variable, if not set, is 4000.
  3. It controls whether or not to allow unknown keys in the environment variables.
  4. If true, stops validation on the first error; if false, returns all errors.
  5. This allows to use your custom ConfigModule with dependency injection in other NestJS modules.

Update the App module to include the Config module

Update the AppModule to make the Config module available for the entire application.

backend/src/app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppGateway } from "./app.gateway";
import { AppService } from "./app.service";
import { ConfigModule } from "./config/config.module";

@Module({
	imports: [ConfigModule],
	controllers: [AppController],
	providers: [AppService, AppGateway],
})
export class AppModule {}

Inject the Config service in the WebSocket gateway

In order to illustrate the usage of the Config module with NestJS dependency injection, update the WebSocket gateway to access the configuration and log a message on module initialization.

backend/src/app.gateway.ts
import { OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
	ConnectedSocket,
	MessageBody,
	OnGatewayConnection,
	OnGatewayDisconnect,
	SubscribeMessage,
	WebSocketGateway,
} from "@nestjs/websockets";
import { Socket } from "socket.io";
import { Point } from "./types/point.type";

@WebSocketGateway({
	cors: {
		origin: "*",
	},
})
export class AppGateway
	implements OnModuleInit /* (1)! */, OnGatewayConnection, OnGatewayDisconnect
{
	constructor(readonly configService: ConfigService /* (2)! */) {}

	onModuleInit() {
		// (3)!
		const port = this.configService.get<number>("PORT"); // (4)!

		console.log(`The WebSocket gateway runs on port ${port}.`); // (5)!
	}

	handleConnection(@ConnectedSocket() socket: Socket): void {
		console.log("A player has connected");
	}

	handleDisconnect(@ConnectedSocket() socket: Socket): void {
		console.log("A player has disconnected");
	}

	@SubscribeMessage("FIRST_POINT_FROM_PLAYER")
	start(@ConnectedSocket() socket: Socket, @MessageBody() point: Point): void {
		socket.broadcast.emit("FIRST_POINT_TO_PLAYERS", point);
	}

	@SubscribeMessage("POINT_FROM_PLAYER")
	addPoint(
		@ConnectedSocket() socket: Socket,
		@MessageBody() point: Point
	): void {
		socket.broadcast.emit("POINT_TO_PLAYERS", point);
	}
}
  1. The OnModuleInit interface force the class to implement the onModuleInit method.
  2. The ConfigService service is injected and available within the class.
  3. The concret implementation of the onModuleInit method from the OnModuleInit interface. This method will be called when the AppGateway is fully initialized.
  4. The PORT environment variable is accessed from the Config service.
  5. A message is displayed with the port.

Use the Config service in the main file

Dependency injection cannot be used in the main file as it was done with the WebSocket gateway. The ConfigService has to be taken from the application that has been initialized and then it can use it.

backend/src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ConfigService } from "@nestjs/config";

async function bootstrap() {
	const app = await NestFactory.create(AppModule);
	const configService = app.get(ConfigService); // (1)!
	const port = configService.get<number>("PORT"); // (2)!
	await app.listen(port as number); // (3)!
}
bootstrap();
  1. Get the ConfigService from the initialized application.
  2. Access to the PORT environment variable. As environment variables are strings by default, we specify the config service the PORT environment variable is a number with get<number>('PORT').
  3. However, the config service might not have the PORT environment variable set and would be undefined. As we use Joi, we know the variable will always be defined, so we force the cast with as number.

Try out the backend and manually set the environment variables

Start the NestJS application.

In a terminal, execute the following command(s).
npm run start:dev

The output of the command should look similar to this.

> backend@0.0.1 start:dev
> nest start --watch

[9:54:42 AM] Starting compilation in watch mode...

[9:54:49 AM] Found 0 errors. Watching for file changes.

[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [NestFactory] Starting Nest application...
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +13ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [InstanceLoader] AppModule dependencies initialized +1ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [WebSocketsController] AppGateway subscribed to the "FIRST_POINT_FROM_PLAYER" message +69ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [WebSocketsController] AppGateway subscribed to the "POINT_FROM_PLAYER" message +0ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [RoutesResolver] AppController {/}: +2ms
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [RouterExplorer] Mapped {/, GET} route +3ms
The WebSocket gateway runs on port 4000.
[Nest] 10766  - 02/10/2023, 9:54:54 AM     LOG [NestApplication] Nest application successfully started +4ms

You notice the port of the WebSocket gateway runs on the default port 4000.

Now stop the application by pressing Ctrl+C in your terminal.

Start the NestJS application with a specific port.

In a terminal, execute the following command(s).
PORT=1234 npm run start:dev

The output of the command should look similar to this.

> backend@0.0.1 start:dev
> nest start --watch

[9:57:52 AM] Starting compilation in watch mode...

[9:57:59 AM] Found 0 errors. Watching for file changes.

[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [NestFactory] Starting Nest application...
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +13ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [InstanceLoader] AppModule dependencies initialized +1ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [WebSocketsController] AppGateway subscribed to the "FIRST_POINT_FROM_PLAYER" message +81ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [WebSocketsController] AppGateway subscribed to the "POINT_FROM_PLAYER" message +0ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [RoutesResolver] AppController {/}: +1ms
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [RouterExplorer] Mapped {/, GET} route +2ms
The WebSocket gateway runs on port 1234.
[Nest] 11387  - 02/10/2023, 9:58:03 AM     LOG [NestApplication] Nest application successfully started +4ms

You notice the port of the WebSocket gateway now runs on the port 1234 that you defined with an environment variable!

An environment variable can be set by setting it in front of the command you want to execute. You'll later see other ways to define environment variable.

Stop the application by pressing Ctrl+C in your terminal.

Store the environment variable in a dedicated .env file

The PORT=1234 npm run start:dev command can be useful to set one specific environment variable but is not very user-friendly for multiple environment variables or to share with other people.

dotenv allows to set these environment variables in a dedicated "dot env" (.env) file. It will then take the values from the .env file, load them as environment variables in a similar matter that you did with the PORT=1234 npm run start:dev command.

Store the value of the port in a dedicated .env file.

backend/.env
# The port on which the backend runs
PORT=4321

Start the NestJS application with the usual command.

In a terminal, execute the following command(s).
npm run start:dev

The output of the command should look similar to this.

> backend@0.0.1 start:dev
> nest start --watch

[10:06:30 AM] Starting compilation in watch mode...

[10:06:36 AM] Found 0 errors. Watching for file changes.

[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [NestFactory] Starting Nest application...
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +15ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [InstanceLoader] ConfigHostModule dependencies initialized +1ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [InstanceLoader] AppModule dependencies initialized +1ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [WebSocketsController] AppGateway subscribed to the "FIRST_POINT_FROM_PLAYER" message +72ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [WebSocketsController] AppGateway subscribed to the "POINT_FROM_PLAYER" message +0ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [RoutesResolver] AppController {/}: +1ms
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [RouterExplorer] Mapped {/, GET} route +2ms
The WebSocket gateway runs on port 4321.
[Nest] 12875  - 02/10/2023, 10:06:40 AM     LOG [NestApplication] Nest application successfully started +3ms

You notice the port of the WebSocket gateway now runs on the port 4321! dotenv did load the environment variables from the .env file!

Let's keep the backend running to test with the frontend.

Frontend steps

Info

All the following steps will be executed in the frontend directory.

In a new terminal, you can switch to the frontend directory with the following command.

In a terminal, execute the following command(s).
cd frontend

At the moment, the backend URL used by the frontend service is fixed to localhost:4000. To be able to connect to other backends on the internet or on other devices, this value must be changed.

Next.js uses dotenv internally as well (so there is no need to install it) but the process defers a bit from NestJS.

In Next.js, runtime environment variables can only be accessed in Next.js Pages (in the src/pages directory). Thus, the pages must provider the environment variables as properties to the React components that need them.

This is because the default process of building React application sets the environment variables at build time. When building your application for production, React will read the values set in environment variables and substitute them in the codebase.

Next.js offers the server side rendering feature that we can use to build dynamic pages where each page can still have access to the real environment variables.

Update the WebSocket provider

Update the WebSocket provider to pass a backendUrl variable as a property of the React component.

frontend/src/component/websocket-provider.tsx
import { createContext, useContext, useEffect, useMemo } from "react";
import { Socket, io } from "socket.io-client";
import { useLines } from "@/components/line-provider";
import { Point } from "@/types/point";

type State = {
	emitFirstPointFromPlayer: Function;
	emitPointFromPlayer: Function;
};

const WebSocketContext = createContext<State | undefined>(undefined);

export type WebSocketProviderProps = {
	// (1)!
	backendUrl: string;
	children: React.ReactNode;
};

function WebSocketProvider(
	{ backendUrl, children }: WebSocketProviderProps /* (2)! */
): JSX.Element {
	const socket: Socket = useMemo(
		() => io(backendUrl /* (3)! */, { autoConnect: false }),
		[backendUrl] // (4)!
	);
	const { dispatch } = useLines();

	useEffect(() => {
		if (!socket.connected) {
			socket.connect();
		}
		return () => {
			socket.close();
		};
	}, [socket]);

	useEffect(() => {
		if (!socket) return;

		socket.on("FIRST_POINT_TO_PLAYERS", (point: Point) => {
			dispatch({ type: "ADD_FIRST_POINT", point: point });
		});

		socket.on("POINT_TO_PLAYERS", (point: Point) => {
			dispatch({ type: "ADD_POINT", point: point });
		});
	}, [socket, dispatch]);

	const emitFirstPointFromPlayer = (point: Point) => {
		socket.emit("FIRST_POINT_FROM_PLAYER", point);
	};

	const emitPointFromPlayer = (point: Point) => {
		socket.emit("POINT_FROM_PLAYER", point);
	};

	return (
		<WebSocketContext.Provider
			value={{ emitFirstPointFromPlayer, emitPointFromPlayer }}
		>
			{children}
		</WebSocketContext.Provider>
	);
}

function useWebSocket(): State {
	const context = useContext(WebSocketContext);
	if (context === undefined) {
		throw new Error("useWebSocket must be used within a WebSocketProvider");
	}
	return context;
}

export { WebSocketProvider, useWebSocket };
  1. Define a type for the WebSocket provider to add a backendUrl property.
  2. Access the properties available to the WebSocket provider.
  3. Use the property for the backend URL.
  4. Add the backendUrl as a dependency of the useMemo function. If the variable backendUrl changes, the useMemo function is called again.

Update the main page

Update the main page to pass the backend URL from the environment variables. The process.env variable contains all the environment variable available to the process.

frontend/src/pages/index.tsx
import { LineProvider } from "@/components/line-provider";
import { WebSocketProvider } from "@/components/websocket-provider";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic";

const Sketch = dynamic(
	() => import("../components/sketch").then((mod) => mod.Sketch),
	{
		ssr: false,
	}
);

type HomeProps = {
	// (1)!
	backendUrl: string;
};

export const getServerSideProps: GetServerSideProps<
	// (2)!
	HomeProps
> = async () => ({
	props: {
		backendUrl: process.env.BACKEND_URL || "localhost:4000", // (3)!
	},
});

export default function Home({
	backendUrl, // (4)!
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
	return (
		<LineProvider>
			<WebSocketProvider backendUrl={backendUrl}>
				{" "}
				{/* (5)! */}
				<Sketch />
			</WebSocketProvider>
		</LineProvider>
	);
}
  1. Define the properties the Home component can have access to.
  2. The getServerSideProps function will be called each time the main page is accessed. It will retrieve the properties of the page from the environment variables and pass them to the React component.
  3. Try to use the BACKEND_URL environment variable. If it is not set, set the value to localhost:4000.
  4. The backendUrl is passed from the getServerSideProps function.
  5. The backendUrl is then passed to your WebSocket provider.

Try out the frontend and manually set the environment variables

Start the Next.js application.

In a terminal, execute the following command(s).
npm run dev

Access http://localhost:3000 in two different windows.

Try to draw in one window. You should notice the drawing doesn't happen in the second window...

This is because the default value to access the backend is localhost:4000 but the backend is still running with the 4321 port.

Stop the Next.js application by pressing Ctrl+C in your terminal.

Start the Next.js application with a specific backend URL.

In a terminal, execute the following command(s).
BACKEND_URL=localhost:4321 npm run dev

Refresh the two windows you just opened and try to draw. It should now work as the backend URL is the right one!

Stop the Next.js application by pressing Ctrl+C in your terminal.

Store the environment variable in a dedicated .env file

Just as with NestJS, you can set the environment variables in a dedicated .env file.

frontend/.env
# The URL to access the backend
BACKEND_URL=localhost:4321

Start the Next.js application with the usual command.

In a terminal, execute the following command(s).
npm run dev

Refresh the two windows you just opened and try to draw. It should still work!

Reset the default environment variables values

Stop both applications.

Update the dotenv file of the backend to set the port back to 4000. Even if there is a default value set with Joi, it is good practice to set all environment variables in the .env file for a quick overview of the available environment variables.

backend/.env
# The port on which the backend runs
PORT=4000

Update the dotenv file of the frontend to set the backend URL back to localhost:4000.

frontend/.env
# The URL to access the backend
BACKEND_URL=localhost:4000

Summary

Congrats! You now have a simple way to update the configuration of your application using environment variables!

There is no need to access and modify the codebase to change trivial settings.

Using environment variables is one of the best practices as mentioned by The Twelve-Factor App methodology. It allows to update the application in different settings without the need of rebuilding the entire application each time. Every time you implement something new, don't forget to search the subject with best practices/proven practices on your favorite search engine, you'll learn so much!

The next chapter, you'll use these environment variables to access your drawing application from your phone! The friends on your network will be able to access it as well and you can draw collaboratively.

Go further

Are you able make usage of environment variables for the stroke color and the stroke size on the frontend side? Expand the next component to see the answer!

Show me the answer!

Update the Sketch to get the stroke color and the stroke size as properties

frontend/src/components/sketch.tsx
import React from "react";
import Konva from "konva";
import { Stage, Layer, Line } from "react-konva";
import { useLines } from "@/components/line-provider";
import { useWebSocket } from "@/components/websocket-provider";

export type SketchProps = {
		strokeColor: string;
		strokeSize: number;
};

export const Sketch = ({
		strokeColor,
		strokeSize,
}: SketchProps) => {
		const { state: lines, dispatch: dispatchLines } = useLines();
		const { emitFirstPointFromPlayer, emitPointFromPlayer } = useWebSocket();
		const isDrawing = React.useRef(false);

		const getPointFromMouseEvent = (
				mouseEvent: Konva.KonvaEventObject<MouseEvent>
		) => {
				return mouseEvent.target.getStage()?.getPointerPosition() ?? { x: 0, y: 0 };
		};

		const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
				isDrawing.current = true;
				const point = getPointFromMouseEvent(e);
				dispatchLines({ type: "ADD_FIRST_POINT", point: point });
				emitFirstPointFromPlayer(point);
		};

		const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
				if (!isDrawing.current) {
						return;
				}
				const point = getPointFromMouseEvent(e);
				dispatchLines({ type: "ADD_POINT", point: point });
				emitPointFromPlayer(point);
		};

		const handleMouseUp = () => {
				isDrawing.current = false;
		};

		return (
				<div>
						<Stage
								width={window.innerWidth}
								height={window.innerHeight}
								onMouseDown={handleMouseDown}
								onMousemove={handleMouseMove}
								onMouseup={handleMouseUp}
						>
								<Layer>
										{lines.map((line, i) => (
												<Line
														key={i}
														points={line}
														stroke={strokeColor}
														strokeWidth={strokeSize}
														tension={0.5}
														lineCap="round"
														lineJoin="round"
												/>
										))}
								</Layer>
						</Stage>
				</div>
		);
};

Update the main page to access the environment variables from the server side.

frontend/src/pages/index.tsx
import { LineProvider } from "@/components/line-provider";
import { WebSocketProvider } from "@/components/websocket-provider";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic";

const Sketch = dynamic(
	() => import("../components/sketch").then((mod) => mod.Sketch),
	{
		ssr: false,
	}
);

export type HomeProps = {
	backendUrl: string;
	strokeColor: string;
	strokeSize: number;
};

export const getServerSideProps: GetServerSideProps<
	HomeProps
> = async () => ({
	props: {
		backendUrl: process.env.BACKEND_URL || 'localhost:4000',
		strokeColor: process.env.STROKE_COLOR || '#df4b26',
		strokeSize: parseInt(process.env.STROKE_THICKNESS as string, 10) || 5, // (1)!
	},
});

export default function Home({
	backendUrl,
	strokeColor,
	strokeSize,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
	return (
		<LineProvider>
			<WebSocketProvider backendUrl={backendUrl}>
				<Sketch strokeColor={strokeColor} strokeSize={strokeSize} />
			</WebSocketProvider>
		</LineProvider>
	);
}
  1. As environment variables are always strings, we have to cast the string to a number.

Start the backend.

Start the frontend with custom environment variables.

In a terminal, execute the following command(s).
STROKE_COLOR="DarkOrchid" STROKE_THICKNESS=20 npm run dev

Access http://localhost:3000 and try to draw. The color is updated as well as the stroke size!

Stop the frontend.

Store the environment variables in the dedicated .env file.

frontend/.env
1
2
3
4
5
6
7
8
# The URL to access the backend
BACKEND_URL=localhost:4000

# The color of the stroke
STROKE_COLOR=DarkOrchid

# The size of the stroke
STROKE_THICKNESS=20

Start and access the frontend again, your environment variables are used!