An easy dotenv implementation for Elixir that provides:
- Simple loading of
.env
files with support for inheritance and interpolation. - Strong validation and type casting of environment variables.
- Environment specific configuration.
This library is heavily inspired from Dotenvy and provides a similar experience.
- Installation
- Basic Usage
- File loading
- The
env!
functions - Dotenv File Syntax Cheatsheet
- Environment Files Inheritance
As usual, pull the library from your mix.exs
file.
def deps do
[
{:nvir, "~> 0.10"},
]
end
You will generally use Nvir
from your config/runtime.exs
file.
- Import the module functions, and call
dotenv!/1
to load your files. - Use
env!/2
to require a variable and validate it. - Use
env!/3
to provide a default value. Default values are not validated.
Note that you do not have to call dotenv!/1
to use the env
functions. You can
use this library for validation only.
# runtime.exs
# Import the library
import Nvir
# Load your env files for local development
dotenv!([".env", ".env.#{config_env()}"])
# Configure your different services with the env!/2 and env!/3 functions.
config :my_app, MyAppWeb.Endpoint,
# expect values to exist
secret_key_base: env!("SECRET_KEY_BASE", :string!),
url: [host: env!("HOST", :string!), port: 443, scheme: "https"],
# or provide a sane default
http: [ip: {0, 0, 0, 0}, port: env!("PORT", :integer!, 4000)]
This is most of what you need to know to start using this library. Below is an advanced guide that covers all configuration and usage options.
The dotenv!/1
function accepts paths to files, either absolute or relative to
File.cwd!()
(which points to your app root where mix.exs
is present).
There are different possible ways to chose the files to load.
dotenv!(".env")
Non-existing files are safely ignored. Your .env
file will likely
not be present in production, and you may have a .env.test
file but no
.env.dev
file.
dotenv!([".env", ".env.#{config_env()}"])
It is possible to wrap the sources in tagged tuples, to limit the loading of the sources to certain conditions:
In this example, a different file is loaded depending on the current
Config.config_env()
.
dotenv!(
dev: ".env",
test: ".env.test"
)
This gives you more control on the files that are loaded, and ensures that no file will be loaded in production if the env files are committed to git by mistake and/or included in your releases.
Refer to the documentation of Nvir.dotenv!/1
to know the default enabled tags.
Tagged tuples and lists do not have to contain a single file. They can also contain other valid sources, that is, nested lists or tuples.
For instance, here in :test
environment we will load two files, plus an
additional file if the :ci
tag is enabled.
dotenv!(
dev: ".env",
test: [".env", ".env.test", ci: ".env.ci"]
)
It is also possible to pass the same key multiple times:
dotenv!([
test: ".env.test",
ci: ".env.ci",
test: ".env.test.extra"
])
The files loaded by this library will not replace variables already defined in the real environment.
That is, as your HOME
variable already exists, defining HOME=/somewhere/else
in and .env
file will have no effect.
A special tag can be given to dotenv!/1
to overwrite system variables:
dotenv!([".env", overwrite: ".env.local"])
With the code above, any variable from .env
that does not already exists will
be added to the system env, but all variables from .env.local
will be set.
Just like environment specific keys, the :overwrite
key accepts any nested
source types. The following forms are equivalent:
dotenv!(
dev: [".env.dev", overwrite: ".env.local.dev"],
test: [".env.test", overwrite: ".env.local.test"]
)
dotenv!(
dev: ".env.dev",
test: ".env.test",
overwrite: [dev: ".env.local.dev", test: ".env.local.test"]
)
In :dev
environment, the two snippets above would both result in loading
".env.dev"
then ".env.local.dev"
.
The first level of :overwrite
will determine which group a file belongs to.
Nesting :overwrite
tags has no effect. In the following snippet, all files
except 1.env
are overwrite files.
dotenv!(
dev: "1.env",
overwrite: ["2.env", dev: ["3.env", overwrite: "4.env"]]
)
The 3.env
file is wrapped in an :overwrite
tag, indirectly.
The dotenv!/1
function follows a couple rules when loading the different
sources:
- Files are separated in two groups, "regular" and "overwrites".
- Within each group, files are always loaded in order of appearance. This is important for files that reuse variables defined in previous files.
- The "regular" group is loaded first. The files from the "overwrite" group will see the variables defined by the "regular" group.
The order of execution is the following:
- Load all regular files in order.
- Patch system environment with non-existing keys.
- Load all overwrite files in order.
- Overwrite system environment with all their values.
This means that the following expression will not load and apply
.env.local
first because it belongs to the "overwrite" group, which is applied
last. But .env1
will always be loaded before .env2
.
dotenv!(overwrite: ".env.local", dev: ".env1", dev: ".env2")
It is possible to customize the way the files are loaded. The order of the files is deterministic and cannot be changed, but options exist to change how they are loaded.
The simple way is to start from the default loader and change its properties:
# runtime.exs
import Config
import Nvir
dotenv_loader()
|> enable_sources(:docs, config_env() == "docs")
|> enable_sources(:release, env!("RELEASE_NAME", :boolean, false))
|> dotenv_configure(cd: "/app/release/env")
|> dotenv!(
dev: ".env",
test: ".env.test",
docs: ".env.docs",
release: "/var/release.env"
)
In the example above, we will enable the :docs
and :ci
tag when the defined
conditions are met.
Plus, we changed the directory where the .env files are loaded from. This will
not affect the /var/release.env
file since it's an absolute path.
Please refer to the documentation of dotenv_configure/2
to learn more about
the available options.
It is also possible to redefine predefined tags. Here we replace the :test
tag
with a possibly different boolean value.
# runtime.exs
import Config
import Nvir
dotenv_loader()
|> enable_sources(:test, config_env() == :test and MyApp.some_custom_check())
|> dotenv!(
dev: ".env",
test: ".env.test"
)
The :overwrite
tag cannot be changed, as it is handled separately from other
tags.
Use dotenv_new()
instead of dotenv_loader()
to get an empty loader without
any enabled tag.
If you want to parse the .env files yourself, or add support for other file
formats, pass an implementation of the Nvir.Parser
behaviour as the :parser
option:
# runtime.exs
import Config
import Nvir
dotenv_new()
|> dotenv_configure(parser: MyApp.YamlEnvParser)
|> dotenv!("priv/dev-env.yaml")
The env!
functions allows you to load an environment variable and cast its
content to the appropriate type.
Calling env!(var, caster)
will attempt to fetch the variable, just like
System.fetch_env!/1
does, cast its value, and return it.
A System.EnvError
exception will be raised if the variable is not defined.
An Nvir.CastError
exception will be raised if the cast fails.
Calling env!(var, caster, default)
will use the default value if the key is
not defined.
The function will not use the default value if the cast of an existing key
fails. This will still raise an Nvir.CastError
.
The default value is not validated, so you can for instance call
env!("SOME_VAR", :integer, :infinity)
, whereas :infinity
is not a valid
integer.
Casters come into three flavors:
- The "value as is" one.
- The "nil" one with a
?
suffix that converts empty strings tonil
. It will however not fallback to the default value given toenv!/3
if the key exists. - The "bang" one with a
!
suffix that will raise anNvir.CastError
exception for empty strings and special cases described below.
In some languages, using null
where an integer is expected will cast the value
to a "default value", generally 0
for integers. This is not the case in
Elixir. To respect that, casters for such types behave the same with and without
the !
suffix. Namely, :integer
and :float
will raise for empty strings.
It is however not the case for :existing_atom
, because the :""
atom is
generally defined by the system long before an application starts, in Erlang
just as in Elixir.
Empty strings occur when a variable is defined without a value:
HOST=localhost # value is "localhost"
PORT= # value is ""
Caster | Description |
---|---|
:string |
Returns the value as-is. |
:string? |
Converts empty strings to nil . |
:string! |
Raises for empty strings. |
Caster | Description |
---|---|
:boolean |
"false" , "0" and empty strings become false , any other value is true . Case insensitive. It is recommended to use :boolean! instead. |
:boolean! |
Accepts only "true" , "false" , "1" , and "0" . Case insensitive. |
Caster | Description |
---|---|
:integer! |
Strict integer parsing. |
:integer? |
Like :integer! but empty strings becomes nil . |
:float! |
Strict float parsing. |
:float? |
Like :float! but empty strings becomes nil . |
Caster | Description |
---|---|
:atom |
Converts the value to an atom. Use the :existing_atom variants when possible. |
:atom? |
Like :atom but empty strings becomes nil . |
:atom! |
Like :atom but rejects empty strings. |
:existing_atom |
Converts to existing atom only, raises otherwise. |
:existing_atom? |
Like :existing_atom but empty strings becomes nil . |
:existing_atom! |
Like :existing_atom but rejects empty strings. |
Those exist for legacy reasons and should not be used.
Caster | Description |
---|---|
:boolean? |
Same as :boolean . |
:integer |
Same as :integer! . |
:float |
Same as :float! . |
The second argument to env!/2
and env/3
also accept custom validators using an fn
. The given function must return {:ok, value}
or {:error, message}
where message
is a string.
env!("URL", fn
"https://" <> _ = url -> {:ok, url}
_ -> {:error, "https:// is required"}
end)
It is also possible to return directly an error from Nvir.cast/2
:
env!("PORT", fn value ->
case Nvir.cast(value, :integer!) do
{:ok, port} when port > 1024 -> {:ok, port}
{:ok, port} -> {:error, "invalid port: #{port}"}
{:error, reason} -> {:error, reason}
end
end)
# Simple assignment
KEY=value
# With spaces around =
KEY = value
# Empty value
EMPTY=
EMPTY=""
EMPTY=''
# The parser will ignore an "export" prefix
export KEY=value
# Interpolation with previously defined variable
PATH=/usr/bin
PATH=$PATH:/home/alice/bin
PATH=/usr/local/bin:$PATH
Comments are supported on their own line or at the end of a line.
Important, when a value is not quoted, the comment #
character must be
separated by at least one space, otherwise the comment will be included in the
value.
# This is a comment on it's own line
KEY=value # Inline comment
KEY=value# No preceding space, this is part of the value
Trailing whitespace is automatically removed from the end of a value
In this example, some spaces are represented with the _
symbol to make it look
more explicit.
# KEY will contain "value"
KEY=value # Inline comment
# KEY will contain "value" too
KEY=value____
# Multiline strings (with simple and double quotes) are NOT trimmed.
# KEY will contain "Hello! \nHow are you \n"
KEY="""
Hello____
How are you____
"""
# Empty Whitespace is trimmed
# KEY will contain ""
KEY=____
# Use quotes to express whitespace
INDENT=" "
- Quotes are optional.
- Double quotes let you write escape sequences.
- Single quotes define verbatim values. No escaping is done except for the single quote itself.
KEY=raw value with spaces
KEY="value with spaces"
KEY="escape \"quotes\" inside"
KEY="supports \n \r \t \b \f escapes"
KEY='value with spaces'
KEY='no escapes \n' # value will have a "\" character followed by a "n".
KEY='escape \'quotes\' inside'
The same rules applies for escaping as in single line values:
- Double quotes let you write escape sequences.
- Single quotes define verbatim values. No escaping is done except for the single quote itself.
KEY="""
Line 1
Line 2 with "quotes"
"""
KEY='''
Line 1
Line 2 with 'quotes'
'''
Nvir
supports variable interpolation within env files. Please refer to the
"Environment Files Inheritance" section for more details on which value is used
on different setups.
# This variable can be used below in the same file
GREETING=Hello
# Basic syntax
MSG=$GREETING World
# Enclosed syntax
MSG=${GREETING} World
# Not interpolated (single quotes)
MSG='$GREETING World'
# In raw values, a comment without a preceding space will be included in the
# value
MSG=$GREETING# This is part of the value
MSG=${GREETING}# This too
MSG=$GREETING # Actual comment
These rules apply to the regular group. Overwrite files will always have their values added to system environment.
- System environment variables always take precedence over env files.
- Multiple env files can be loaded in sequence, with later files overwriting earlier ones
- Variable interpolation (
$VAR
) uses values from the system first, then from the most recently defined value.
# System state:
# WHO=world
# .env
WHO=moon
HELLO=hello $WHO # Will use WHO=world from system since we are not overwriting
# Result:
# HELLO=hello world
With multiple files, we use the latest value. In this exemple the variable is not already defined in the system:
# .env
WHO=world
# .env.dev
WHO=mars
HELLO=hello $WHO # This defines HELLO=hello mars
# .env.dev.2
WHO=moon
HELLO=hello $WHO # this defines HELLO=hello moon
# With loading order of .env, .env.dev, .env.dev.2
# we will have the following:
# WHO=moon
# HELLO=hello moon
So, "regular" files do not overwrite the system environment, but they act as a group and overwrite themselves as if they were a single file.
When a variable using interpolation is not redefined in subsequent files, it keeps using the value from when it was defined.
# .env
WHO=world
# .env.dev
WHO=mars
HELLO=hello $WHO # HELLO is defined here, uses WHO=mars
# .env.dev.2
WHO=moon # WHO is updated, but HELLO keeps its value
# since it's not redefined here
# Final result:
# WHO=moon
# HELLO=hello mars # Not "hello moon"!
This may cause inconsistencies if you code depends on the values of both HELLO
and WHO
.
Overwrite files follow the same logic, each file overwrites the previous ones.
The only difference is that the values in the files take precedence over any preexisting variable in the system environment.
The edge case documented above still applies.