Minimal Setup
View SourceHere is another demonstration of how you can configure your app for maximum simplicity: less is more. Per the 12-Factor App, this strategy helps "Minimize divergence between development and production". In some cases, the dev
, prod
, and test
versions of the app can be identical (even down the the md5 hash) because the only differences are the configuration values supplied at runtime.
To see this strategy in action, we will abolish the dev.ex
, prod.exs
, and test.exs
entirely and leave only the compile-time config (config/config.exs
) and the runtime configuration (config/runtime.exs
).
To try this out:
- Use the
dot.new
generator to generate a new application, e.g.mix dot.new sparse
- Delete the env-specific compile-time configs:
config/dev.exs
,config/prod.exs
, andconfig/test.exs
. - Remove the line at the end of
config/config.exs
that usesimport_config
to load these other files. - If environment-specific compile-time configurations are needed, update your
config/config.exs
to include blocks for each config environment.
Here is an example of the stripped down config/config.exs
file:
config/config.exs
# compile-time config
import Config
# Dev
if config_env() == :dev do
config :logger, :console,
level: :debug,
format: "[$level] $message\n"
end
# Test
if config_env() == :test do
config :logger, :console,
level: :warning,
format: "[$level] $message\n"
end
# Prod
if config_env() == :prod do
config :logger, :console,
format: "$time $metadata[$level] $message\n",
level: :info,
metadata: [:request_id]
end
Runtime vs. Compile-time Considerations
Logger configuration is an interesting example -- because the Logger
relies on
macros, it is affected by compile-time considerations. Although you can configure
the logger level at runtime, that has a different effect than configuring it at
compile-time. If you set the logger level to :info
at runtime, then you will only
see info, warning, or error messages in the logs... but the important thing is that
the calls to Logger.debug/2
are still there in the code. Calls to those functions
are still made; only the output is silenced.
By comparison, if you set the logger level to :info
in the compile-time config, all
calls to Logger.debug/2
are removed from the compiled application. Calls are no
longer made to Logger.debug/2
because that function no longer exists. That
distinction may not matter for small apps, but you can imagine that those
milliseconds can add up for mission-critical applications where performance is
paramount.
TL;DR: Sometimes it is better to have the flexibility to change the logging level at runtime (e.g. to help debug some tricky problem) and sometimes it's better to control this at compile-time. You decide.
Phoenix relies on macros to generate routes, so you must configure certain things at compile time: its routes are compiled into existence. Consider the following bits of configuration from a Phoenix application:
# Compile-time config
config :phoenix_live_view,
debug_heex_annotations: true,
enable_expensive_runtime_checks: true
# Enable dev routes for dashboard and mailbox (compile-time config)
config :your_app, dev_routes: true
Those must be defined at compile-time because it controls how the application gets built. You can put these types of configuration details into the appropriate environment block in the compile-time config.exs
.
config/runtime.exs
Your runtime.exs
can include whatever it needs to, i.e. all the application configuration that can happen at runtime and you know that the calls to Dotenv.source/2
will be responsible for loading up the proper .env
files for the given environment.
import Config
import Dotenvy
env_dir_prefix = System.get_env("RELEASE_ROOT") || Path.expand("./envs")
source!([
Path.absname(".env", env_dir_prefix),
Path.absname(".#{config_env()}.env", env_dir_prefix),
Path.absname(".#{config_env()}.overrides.env", env_dir_prefix),
System.get_env()
])
config :myapp, MyApp.Repo,
database: env!("DATABASE", :string!),
username: env!("USERNAME", :string),
password: env!("PASSWORD", :string),
hostname: env!("HOSTNAME", :string!),
pool_size: env!("POOL_SIZE", :integer),
adapter: env("ADAPTER", :module, Ecto.Adapters.Postgres),
pool: env!("POOL", :module?)
# etc...
Optimizing Compilation Time
By default, your Elixir application compiles differently between environments, so you end up with multiple compiled artifacts in your _build
directory. All this extra time spent compiling and re-compiling can really add up, so it's worth asking whether or not it's necessary.
If you have an app that can be fully configured at runtime and there are no differences between the compiled versions, then you can set the :build_per_environment
option in yourmix.exs
so that all environments use the same compiled code and you no longer need to re-compile your app between environments.
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
releases: releases(),
build_per_environment: false
]
end
In some cases, you may be able to leverage this even if there are differences between dev
and prod
, but you'll have to explore the subtleties with your particular app. For example, if dev
and test
can share the same compiled artifacts, that may never conflict with the prod
artifacts which may only need to be compiled on a build machine as part of your deployment pipeline. YMMV.
See Mix Project configuration for the official docs.
See Also
See the article about using Dotenvy
in your application for more discussion on this and other approaches.