Why Your systemd Service Never Sees Your Shell's Variables
You export API_URL in your shell, run the program by hand and it works. Then you start it as a systemd service and it behaves as if the variable never existed. That is because it never did, as far as the service is concerned.
The reason is simple: the service is not a child of your shell. It is started by systemd (PID 1, or your per-user manager), and it inherits whatever environment systemd has, plus whatever the unit file adds. Your export only ever touched your shell and its children.
Where the service actually comes from
When you type sudo systemctl restart myapp, the systemctl command only sends a request over D-Bus. The manager then forks the process itself. Nothing from the environment of systemctl, sudo or your terminal travels along with that request.
That also explains a few related surprises:
- Anything set in
~/.bashrc,~/.profileor/etc/profile.d/is not read, because no login or interactive shell is involved. PATHis a short default, not the one you are used to.HOME,USERand similar are only set if the unit saysUser=.sudo -Emakes no difference, since sudo is not in the chain.
Look at what the service really has
Do not guess; check. Ask systemd what it configured, then ask the kernel what the process actually received.
systemctl show myapp -p Environment -p EnvironmentFiles
pid=$(systemctl show myapp -p MainPID --value)
sudo tr '\0' '\n' < /proc/$pid/environ
The second command is the ground truth. /proc/PID/environ holds the initial environment as a NUL-separated list, hence the tr. If your variable is not there, the program cannot see it, whatever the unit file seems to say.
Quick detour: the manager has its own environment too
Hang on, this bit is interesting. systemd keeps a global environment block for the manager, and everything it starts inherits from it. You can see it with systemctl show-environment.
You can add to it with systemctl set-environment NAME=value, or copy variables from your shell with systemctl import-environment NAME. That does work, but it is global and lost on reboot, so it is a debugging tool rather than a configuration method. For user services, systemctl --user import-environment is what desktop sessions use to hand over things like DISPLAY.
Setting variables properly in the unit
There are three sensible routes, in rough order of how often you want them.
Environment=for a few non-secret values.EnvironmentFile=for a longer list, or values that differ per host.LoadCredential=for secrets (more on that below).
Edit with a drop-in rather than touching the packaged unit:
sudo systemctl edit myapp
[Service]
Environment=API_URL=https://api.example.org
Environment="GREETING=hello world"
EnvironmentFile=-/etc/myapp/myapp.env
The leading - on the file path means "do not fail if it is missing". Leave it off if the file is mandatory and you want a loud failure at start.
The file format is not shell
An EnvironmentFile looks like shell but is not parsed by one. Plain KEY=value lines are the safe choice.
# /etc/myapp/myapp.env
API_URL=https://api.example.org
LOG_LEVEL=debug
GREETING="hello world"
No export keyword, no $(command), no $OTHER expansion inside values. Lines starting with # or ; are comments. If you tried to reuse a file that you normally source, check each line against these rules.
Pitfalls that waste an afternoon
- Forgetting to reload. After editing a unit or drop-in, run
sudo systemctl daemon-reload, then restart.systemctl editdoes the reload for you; hand edits do not. - Editing the env file but not restarting. The file is read when the service starts, not continuously.
- Percent signs. systemd expands specifiers like
%hand%nin unit files, so a literal%in a value needs writing as%%. - Using
$VARinExecStart=and expecting a shell. systemd does substitute${VAR}and$VARfrom the unit's own environment, but there is no globbing, piping or quoting beyond systemd's own rules. Wrap in/bin/sh -cif you really need a shell. - Relying on
PassEnvironment=. It copies variables from the manager's own environment (system instance only), not from your shell. It is rarely what you want.
Secrets deserve better than Environment=
Values set in the unit are visible to anyone who can run systemctl show, and the process environment is readable through /proc/PID/environ by the same user and root. Environment files with mode 0600 help with the first problem only.
For anything sensitive, LoadCredential= hands the service a file under $CREDENTIALS_DIRECTORY that only that service can read, and the program reads the file instead of an environment variable.
[Service]
LoadCredential=dbpass:/etc/myapp/dbpass
# in the program: read $CREDENTIALS_DIRECTORY/dbpass
That does mean changing the program to read a file. If you cannot, a 0600 environment file owned by root is the pragmatic fallback, with the exposure through /proc understood and accepted.
A one-off test without editing anything
To check a value reaches a process without touching the unit, systemd-run can set it for a transient service:
sudo systemd-run --wait --pipe -p Environment=FOO=bar env
If FOO=bar appears in the output alongside a very short list of other variables, you have just seen exactly what a service gets, which is the whole point. Compare that with env in your terminal and the gap you were chasing is obvious.