Handling env vars in a clean way in Django?

I’m trying to deploy a Django app.

To respect 12 factors as possible as I can, in my settings.py, I get multiple setting values from the environment.

For example: SECRET_KEY = os.environ['DJ_SECRET_KEY'] where SECRET_KEY is a Python variable built from DJ_SECRET_KEY environment variable.

In my Dockerfile, I need to run a Django command named collectstatic whose purpose is to gather static files and copy them all to a specified folder. In order to work, this command requires to read the entire settings.py.

This means all the environment variables required in the settings file must be accessible both at build time and run time, otherwise, it would raise an error.

From this post I understood that I would need to call fly deploy --build-arg NAME=VALUE to pass env var to my Dockerfile, and then use ARG in Dockerfile.

If this is actually the way to do it, I can see many issues with that:

  1. Am I really supposed to pass 20 or 30 arguments to the deploy command to deploy every time? Even if I guess this can be scripted, this seems an horrible method to me.
  2. This is not DRY, so I could theoretically make a mistake in which build-time vars would differ from run-time vars.
  3. How my secrets are supposed to keep secret if I (or any other teammate) need to pass them manually to a command?

How would you solve my problem in a an elegant way?

Related question: If I remove the collectstatic cmd from Dockerfile, and put it in [[deploy]] section of fly.toml instead, is it a good workaround? At this point I’m not sure to understand the exact consequences of doing this, but I know that Heroku is using build time to collect static files (and keep the release phase to things like database migrations).

Thanks for your time!

1 Like

We have a django app with plenty of stuff (well, maybe a dozen parameters) coming from env. We don’t have to pass any vars during build. I took a look and here’s why:

  1. We pull from env with a default, eg DJANGO_ENV = os.environ.get('DJANGO_ENV', 'local')
  2. We switch as much as possible in code, on DJANGO_ENV, i.e., avoid having env vars “just because”.
    • That’s a sticking point of mine - if it isn’t (a) a secret, nor (b) dynamic (beyond the specific environment), it doesn’t need to be dynamically injectable.
  3. In some cases, where we really must have a value from env, we have switches like if DJANGO_ENV == "production": assert SECRET_KEY

In our Dockerfile, collectstatic runs without any env injected and with an effective DJANGO_ENV = "local".

Thanks for sharing. However, what I want to achieve is actually the opposite. I aim for a strict separation between config and code, as stated in 12 factors: The Twelve-Factor App

As soon as you write ‘local’ or ‘production’ in your settings file, you break the rule. I can’t overstate how having a codebase completely unlinked to environment is valuable IMO. For example you can create a new environment with no push.

That’s totally fair — if it’s a benefit you or your team leverage: regularly composing new environments. Sample size of a few year & dozens of services, it was definitely not necessary for us, and our apps are otherwise indistinguishably 12-factor in design.

We also love being able to manage and observe most config changes (which themselves are infrequent) through the existing tools for code and deploys, vs thinking about a parallel kind of change management.

But, no need to change eachother’s minds on this! I just don’t have a much better suggestion for the original issue here…

1 Like

What if you skip parts of the file upon noticing a (single) BUILD var is set, rather than being an environment check? You gain the ability of not requiring 30 (unused?) vars on build, but yet you aren’t segmenting into environment specifics.

Hi David, I’m using python-decouple in my django (4.0) app and found the same error the first time I tried to deploy with settings using decouple’s config() without default values, because env vars configured with flyctl was not available in deploy stage, as I figured out searching for a solution in this forum.

My workaround was to set default values wenever possible, even with empty strings, and a valid database_url format string as default with fake credentials.

Like:

SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = config("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", default="")

and

DATABASES = { "default": dj_database_url.parse( config( "DATABASE_URL", default="postgres://user:pass@localhost:5432/database", ), conn_max_age=600, ) }

and just worked fine, so far.