Guide

Flask Config, Environment and Secrets

Part 4 of 6By Amith Kumar6 min read
Part 4 of 6Python Web Apps in Production: A Hands-On Guide
  1. 1Set Up a venv and Run a Python Web App
  2. 2Run gunicorn Under systemd
  3. 3Put gunicorn Behind nginx
  4. 4Flask Config, Environment and Secrets
  5. 5Workers and Concurrency
  6. 6Zero-Downtime Reload and Health Checks
Verified 22 Jul 2026 · Ubuntu 24.04 LTS

Configure a Python app with environment and secrets

Handling Python secrets well means the value lives in one root-owned file, the app reads it from the environment, and no log line ever prints it in the clear. Hard-coding an API key in app.py puts it in your git history forever, and printing it on boot leaks it into journald. This chapter configures the app through the environment, stores its secret in a file only the service user can read, and proves the app loaded it without exposing it. Every command ran on a live Ubuntu 24.04 server.

Key takeaways
  • Read every setting from the environment, so the same code runs in development and production with different values.
  • Keep production secrets in a systemd EnvironmentFile at mode 600, owned by the service user, outside the code directory.
  • Log only a masked preview of a secret, never the value, so journald never becomes a place credentials leak.
  • Add the local .env to .gitignore and commit a .env.example instead, so a real secret never reaches the repository.

Configuration and secrets are the same mechanism with different care. Both come from the environment, which keeps them out of the code. The difference is that a secret also needs tight file permissions and must never be logged. Get both right once and the pattern carries to every app you run.

Prerequisites

Before you start
  • The flaskapp service from earlier parts, running under flaskuser.
  • Root access to create a file under /etc/flaskapp and set its permissions.
  • A git repository for the app, so the ignore rules below have somewhere to apply.

Read config from the environment

The app already reads APP_ENV from the environment. Do the same for the secret and any other setting, and log only a masked preview at startup so you can confirm it loaded without printing it.

import os
SECRET = os.environ.get('APP_SECRET', '')

def secret_preview(s):
    return (s[:3] + '***' + s[-2:]) if len(s) > 6 else '***'

# at boot, log the preview, never the value
log_line = {'env': os.environ.get('APP_ENV'), 'secretLoaded': bool(SECRET),
            'secretPreview': secret_preview(SECRET)}

The app never returns the secret from a route and never logs the full value. It reports only whether the secret loaded and a short masked preview, which is enough to debug a missing or wrong value.

Store the secret in an EnvironmentFile

Put production values in /etc/flaskapp/flaskapp.env, outside the code directory. Create it as root, lock it to the service user, and set mode 600 so no other account can read it.

sudo tee /etc/flaskapp/flaskapp.env >/dev/null <<'ENV'
APP_ENV=production
APP_SECRET=sk_live_your_secret_key_here
APP_GREETING=Bound stories, shipped the week they print.
ENV
sudo chown flaskuser:flaskuser /etc/flaskapp/flaskapp.env
sudo chmod 600 /etc/flaskapp/flaskapp.env

Then reference it from the unit with EnvironmentFile=/etc/flaskapp/flaskapp.env and restart. Confirm the permissions and that systemd loaded the file.

stat -c '%A %a %U:%G' /etc/flaskapp/flaskapp.env
systemctl show flaskapp -p EnvironmentFiles
stat showing the environment file at mode 600 owned by the service user, and systemd confirming it loaded the EnvironmentFile

The file is -rw-------, mode 600, owned by flaskuser, so only the service user and root can read it. systemd confirms it loaded the EnvironmentFile, which injects those variables into the process without them ever touching the code.

Prove the app read it, without leaking it

Ask the app its status through nginx, then read the boot line from journald. The status route should report production and that the secret loaded, and the log should show only the masked preview.

curl http://127.0.0.1:8095/api/status
journalctl -u flaskapp -n 2 -o cat
The status route reporting the production environment and the secret loaded, with the boot log showing only a masked preview of the secret, never the value

The status route reports env as production and secretLoaded true, and it never returns the value. The boot line shows secretPreview as sk_***a4, a masked stand-in. The real secret is in memory doing its job, and nowhere in the logs.

Keep secrets out of git

The development secret lives in a local .env you never commit. Ignore it in .gitignore and commit a .env.example with placeholder values, so a new machine knows which variables to set without ever seeing a real one.

cat .gitignore
git status --short
git check-ignore -v .env
git status listing the example file and gitignore as staged while the real env file is absent, and git check-ignore printing the rule that excludes it

The staged files are .env.example, .gitignore, and the code, while the real .env is absent from the list because git is ignoring it. git check-ignore prints the exact rule that excludes it, which is the proof you want before a first push.

Checkpoint

curl http://127.0.0.1:8095/api/status should report "env":"production" and "secretLoaded":true, while no log line prints anything but a masked sk_*** preview. If secretLoaded comes back false, the EnvironmentFile did not load, so re-check its path and run daemon-reload.

EnvironmentFile overrides Environment
  • If a variable is set by both Environment= and an EnvironmentFile, the file wins, regardless of their order in the unit.
  • So keep operational values like the bind address in the unit, and secrets in the file, to avoid a surprise override.
  • After editing either, run daemon-reload and restart, because systemd reads them at start.

Frequently asked questions

Should I use python-dotenv on the server?

On a developer laptop, python-dotenv loading a local .env is convenient. On the server, prefer the systemd EnvironmentFile, because it keeps the secret in a root-owned file at mode 600 and injects it into the process without the app reading a file at a known path. Fewer moving parts, and the permissions are enforced by the filesystem rather than by your code remembering to be careful.

Why mode 600 and not 640 with a group?

Mode 600 gives read and write to the owner only, so a single account, the service user, can see the secret. A 640 file adds group read, which is fine only if you trust every member of that group with the credential. For a production secret, keep the circle as small as possible. When only one service reads the file, 600 owned by that service user is the tightest sensible setting.

How do I rotate the secret without downtime?

Write the new value into the EnvironmentFile, then reload the service so workers pick it up. A later chapter covers gunicorn's graceful reload, which replaces workers without dropping requests, so the new secret takes effect while traffic keeps flowing. Keep the old value valid at the provider until every worker has cycled, then revoke it, so no in-flight request fails during the swap.

What if I already committed a secret by accident?

Treat it as compromised and rotate it at the provider immediately, because it is in the git history even after you delete the file. Removing it from history with a tool like git filter-repo is worth doing, but rotation is the part that actually protects you. Then add the file to .gitignore so it cannot happen again, and verify with git check-ignore.

What you have, and what comes next

You have config coming from the environment, a production secret in a 600 file the service user reads, a masked preview in the logs instead of the value, and the local secret kept out of git. Nothing sensitive is hard-coded or printed.

Next you size gunicorn's workers to the machine, so both CPU cores serve traffic, and watch concurrent requests land on different workers.


Ship it on your own slice

Run your Python app on a slice

Move off the dev server onto a real one. Launch a slice, run Flask or FastAPI under gunicorn and systemd behind Nginx from this guide, and go to production properly.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot