Hey there! 👋 I'm back at it again with another little Flutter post.
In this case, I will take you through how to set up and use environment variables inside your Flutter apps to keep your secret data safe. I will show a couple of solutions, one without any external packages, and one using the dotenv
package. I will also show you how to handle different environments (e.g. stage, sandbox, prod, etc...)
Table of Contents
But first, let's take a look at the problem we're trying to solve:
The problem
You might already know that apps usually communicate with some sort of API/service/server via HTTPS. These systems usually have some sort of authentication in place to verify that calls and requests come from a trusted place. When using OAuth for example this is done by making the client send a couple of parameters to verify it's the app. These secret values are usually called "client_secret" and "client_key". But each system will have its own. You might also need to store other kinds of sensitive data you don't want to expose.
As you might already know these kinds of values should not be pushed to your VCS, so each developer working on the project must only have them stored locally.
So let's take a look at some of the solutions:
Solutions
Solution #1 (no dotenv)
There are a couple of solutions without using dotenv. One of them is to have an env.dart
file and an env.dart.dist
file, the env.dart
file will never be pushed to the repo. Meanwhile, the env.dart.dist
will be pushed but will contain empty or placeholder values.
Then when a developer clones the repo he/she will need to copy env.dart.dist
to env.dart
and fill in the correct environment variables.
This is how the env.dart
would look like:
const clientKey = '044ecd1b740d2d3cf228786293b6669c57529080';
const clientSecret = '4981eb58b897c4e65f19396f1a372e6920800127';
This is how the env.dart.dist
would look like:
const clientSecret = '<client_secret>';
const clientId = '<client_id>';
This is a fine solution and will do the job. But I would discourage this approach for a couple of reasons:
- If you name the dist file like the one above, your IDE will not recognize the language as Dart so, no type checks.
- If you instead name it like
env.dist.dart
, whenever you import let's sayclientId
in your app, the IDE will detect that value to be both inenv.dart
andenv.dist.dart
. This way you can make the mistake of importing the dist version instead of the real env. - Data/config is mixed with code, for me this is not a good idea. I prefer separating them to keep things clear.
Solution #2 (dotenv)
Now let's look at how to handle the environment with the dotenv package.
Dotenv allows us to load .env
files inside our flutter apps. .env
files are just a file that contains environment variables, something like this:
CLIENT_SECRET=4981eb58b897c4e65f19396f1a372e6920800127
CLIENT_ID=044ecd1b740d2d3cf228786293b6669c57529080
Okay, now that we know what .env files are let's look at how to set up our project to work with them:
1. Create env files
This is quite straight forwards, create a .env file anywhere you want, just remember the path as we will need it further along. For this example I will create it in /env/.env
, and will look like this:
CLIENT_SECRET=4981eb58b897c4e65f19396f1a372e6920800127
CLIENT_ID=044ecd1b740d2d3cf228786293b6669c57529080
Then create /env/.env.dist
(this will be the file that will be pushed to the VCS). It will look like this:
CLIENT_SECRET= # Replace with the REAL client_secret
CLIENT_ID= # Replace with the REAL client_id
2. Add .env to .gitignore
Before proceeding let's make sure we add the .env file to .gitignore to prevent pushing it by accident. Just add this (make sure it points to the file you created in step 1):
/env/*.env
3. Add .env to pubspec.yml assets
For dotenv to be able to read the file, we must add it to the assets definition in pubspec.yml:
flutter:
assets:
- env/.env
4. Install the dotenv package:
Before we can use the files created above inside our flutter apps, we must first install the dotenv package. This is pretty straightforwards. You can install it via CLI:
flutter pub add dotenv
Or add it to the pubspec.yml file:
dependencies:
dotenv: ^4.0.1
5. Create an Env class
This step is somewhat opinionated and could be done in a variety of ways, you could create consts instead of a class, use a provider, etc... Feel free to handle them in any way you like. I will just show you my approach
I will store this file in: src/lib/config/env.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// Interface for AppEnv. This will help us test our code.
mixin IAppEnv {
String clientSecret;
String clientId;
}
class AppEnv implements IAppEnv {
final String clientSecret = dotenv.get('CLIENT_SECRET', 'default-secret');
final String clientId = dotenv.get('CLIENT_ID', 'default-id');
final String name = 'pre'; // I will explain why I have this here further down
}
final appEnv = AppEnv();
What this class does is load each environment variable from the .env file. dotenv.get
allows us to pass in a fallback value in case the environment variable is not set.
We then instantiate AppEnv and assign it to appEnv
, this is the variable we will use across our app whenever we need to access the environment.
6. Initialize dotenv
This is quite straight forwards, we must add the following to our main.dart main function, before we run our app:
Future<void> main() async {
await dotenv.load(fileName: "env/.env");
runApp(MyApp());
}
- Note that the
fileName
must match the path of our env file and must also be defined in the assets section of the pubspec.yml
After this we can peek into dotenv and see if it indeed has loaded what we expected:
Future<void> main() async {
await dotenv.load(fileName: "env/.env");
print('CLIENT_SECRET ${dotenv.get('CLIENT_SECRET')}');
runApp(MyApp());
}
If everything went well up until this point, we should see this printed to the console:
CLIENT_SECRET 4981eb58b897c4e65f19396f1a372e6920800127
7. We're all setup
After this point, we're all set up and ready to start using the env file inside the app. Let's look at an example using all of the stuff we've done:
class MyApiService extends Service {
/// Use IAppEnv instead of AppEnv, this way we can inject custom envs for testing purposes
final IAppEnv env;
MyApiService({required this.env});
login(String username, String password) {
return this.post(
username,
password,
clientSecret: env.clientSecret,
clientId: env.clientId‚
);
}
}
Handling multiple environments
This is cool and all, but what if our app can run in multiple environments? How do we handle this?
Well, it's quite straightforwards, we just need to create multiple .env files for each env (e.g. stage.env
. prod.env
, etc...) and then decide which one to load before building or running our app.
Flutter & dart don't currently have any kind of hooks we can use to run scripts/commands before an action, which is a shame and makes this a bit more convoluted. But it can be done nonetheless. What I mean is we can't "hook"/act whenever flutter runs or build our app, so we must create a custom script that does it for us.
But let's go step by step:
1. Create files for each environment
I will just handle 2 environments in this example, but you can add as many as you'd like.
These are the files I've created:
env/stage.env
env/prod.env
They're exactly the same as .env
for now, but we will change the values for each environment.
2. Replacing .env with the correct env
Now what we need to do is to replace the .env file with the one for the env we want to run our app against.
For example, if we want to run our app against stage, we must replace .env
with pre.env
. And the same goes for any other environment.
There are multiple ways of doing this:
- you can do it by hand before launching/building your app (discouraged, as it is easy to forget)
- you can use make or some similar build tool
- you can use VSCode tasks
For this particular example, I will be using the VSCode tasks, as it's the way I currently handle this.
For this approach the first thing you need is a script that will handle the replacement of the file, here is what I use (scripts/replace-env.sh
):
#!/bin/bash
envPath='env' # Path to the folder where all our .env files live
env=$1
if [ -z "$1" ]; then
echo "No environment supplied, allowed: [stage, prod]"
exit 1
fi
cp "$envPath/$env.env" "$envPath/.env"
echo "Copied '$envPath/$env.env' to '$envPath/.env'"
- this script receives an env name e.g.
stage
, and tries to replace the current .env file with the one for the specified environment<env>.env
.
Then we just need to call it like this:
$ ./replace-env.sh stage
3. Setting up VSCode tasks
For better ergonomics, I set up a couple of tasks and modify the launch configuration to handle this for us.
Let's start by defining the tasks (.vscode/tasks.json
):
{
"version": "2.0.0",
"tasks": [
{
"label": "replace-env-stage",
"command": "./scripts/replace-env.sh",
"args": ["stage"],
"type": "shell"
},
{
"label": "prepare-env-prod",
"command": "./scripts/replace-env.sh",
"args": ["prod"],
"type": "shell"
},
]
}
- Tasks can be run by pressing
cmd + shift + p
on mac orctrl + shift + p
on windows. Then searching for "Run task", click on the option, then it will show a list of tasks you can run, they should be "prepare-env-prod" and "replace-env-stage". Now you can decide which tasks you want to run.
The benefit of using tasks is that we can use them in conjunction with VSCode launch configurations. So the env is replaced when we run our app from VSCode.
4. Modifying/creating launch config to run tasks
Now that we have the tasks created we can modify or create a launch configuration for each of the envs (.vscode/launch.json
):
{
"version": "0.2.0",
"configurations": [
{
"preLaunchTask": "replace-env-stage",
"name": "App (STAGE)",
"request": "launch",
"type": "dart"
},
{
"preLaunchTask": "replace-env-prod",
"name": "App (PROD)",
"request": "launch",
"type": "dart"
},
]
}
Now in the VSCode "Run & Debug" section, we can select the launch configuration we want to run our app with:
If you select the "App (STAGE)", the task "replace-env-stage" will be executed before running the app. The same goes for any other launch configuration.
5. Building app
Until now it's very useful for development, but if we want to build our app we must run the task or script before running flutter build
. This can also be automated using tasks.
We just need to add a couple more tasks:
{
"version": "0.2.0",
"tasks": [
// Previous tasks
{
"label": "build-stage-apk",
"command": "flutter",
"args": ["build", "apk"],
"type": "shell"
},
{
"label": "build-prod-apk",
"command": "flutter",
"args": ["build", "apk", "--release"],
"type": "shell"
},
{
"label": "Build APK stage",
"dependsOrder": "sequence",
"dependsOn": ["replace-env-stage", "build-stage-apk", "open-apk-path"]
},
{
"label": "Build APK prod",
"dependsOrder": "sequence",
"dependsOn": ["replace-env-prod", "build-prod-apk", "open-apk-path"]
},
{
"label": "open-apk-path",
"command": "open",
"args": ["build/app/outputs/flutter-apk/"],
"type": "shell"
}
]
}
Now instead of building our flutter app manually from the CLI, we can use the tasks we just created to do so. This way it's a lot harder to make mistakes and publish an app that points to an environment it's not supposed to.
Whenever we want to build an APK for prod we just need to open the command palette (cmd + shift + p
), search for "Build APK prod" and run the task. This will:
- replace the environment
"replace-env-prod"
- build the apk
"build-prod-apk"
- open the folder where the apk is located
"open-apk-path"
Note that if you want to build an AAB you can do it in the same way, just create a new task that produces the AAB. You will also need to add a task to open the AAB path, as it's not in the exact location of the APK.
Summary
We're done here, hopefully, I've made sense and given you all the necessary information to set up dotenv in your apps.
Resources
That's it for me this time! Have a great day!
Top comments (0)