SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

systemd Service Environment Variables: Why E No See Am

Your app work for terminal but e fail as systemd service? The service no dey read your .bashrc or find your .env. See how Environment=, EnvironmentFile= and LoadCredential= fix am.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, October 3, 2026.

Why systemd service no dey see your environment variables

systemd service no dey see the environment variables wey you export for your terminal. E no dey read ~/.bashrc, ~/.profile, or anything wey your login shell load. The service start from systemd itself (PID 1, the first process for the box), no be from your shell, so e only get the small environment wey systemd give am. You fix am inside the unit file with Environment=, EnvironmentFile= and WorkingDirectory=, and you pass secrets with LoadCredential=.

Na so plenty small VPS app dey break. Your Node or Python app run well when you type npm start or python3 app.py. You turn am to service, and e crash for startup. The API key na undefined, or the app talk say e no fit find .env. Nothing change for the code. Na the environment change.

When you run sudo systemctl start myapp, systemctl no dey start the app by itself. E send request go systemd, and systemd start the process as its own child. That child no get any link with your shell, so wetin you export for terminal no fit reach am. sudo -E no go help either. The -E only carry your variables reach systemctl, and systemctl no dey pass them give the service. Cron get the same wahala, and why cron job no dey run when the same command work for terminal na the same lesson for another tool.

For this guide we go build one small throwaway service. We go watch am fail, then fix am one step at a time. Each step write wetin the process really receive into file, so you go see am yourself. You no need to trust my word. All the commands na for Ubuntu 24.04.

Build one small app wey go show wetin e receive

The app na shell script wey stand for your Node or Python app. E do two things wey real app dey do. E read one variable, APP_GREETING. E look for .env inside the current directory, the same way the Node dotenv package dey do am. Before e check anything, e write the full env output and the pwd output into /var/tmp/envdemo, so you fit read wetin the service see.

sudo install -d -m 755 /opt/envdemo
sudo install -d -o "$(id -un)" -m 755 /var/tmp/envdemo
sudo tee /opt/envdemo/app.sh > /dev/null <<'EOF'
#!/bin/sh
# Stand-in for your Node or Python app.
OUT=/var/tmp/envdemo
env | sort > "$OUT/env.txt"
pwd > "$OUT/pwd.txt"

if [ -z "$APP_GREETING" ]; then
  echo "APP_GREETING is not set" >&2
  exit 1
fi
if [ ! -r .env ]; then
  echo "cannot read .env in $(pwd)" >&2
  exit 1
fi
if [ -n "$CREDENTIALS_DIRECTORY" ]; then
  echo "secret is $(wc -c < "$CREDENTIALS_DIRECTORY/paystack_secret") bytes"
fi
echo "OK: $APP_GREETING, .env found in $(pwd)"
EOF
sudo chmod 755 /opt/envdemo/app.sh
echo 'DB_NAME=shop' | sudo tee /opt/envdemo/.env > /dev/null

The 'EOF' get quote, so your shell no go expand $APP_GREETING while e dey write the file. The script land for disk exactly as e be for the heredoc.

Now run am the way you dey run your app for terminal:

export APP_GREETING="How far"
cd /opt/envdemo && ./app.sh

You go see OK: How far, .env found in /opt/envdemo. The variable dey there because you export am for this shell. The .env dey there because you cd enter the folder first. The service no go get any of the two.

First run: the service fail, and here be why

Write the unit with heredoc, then run daemon-reload so systemd go read the new file. Type=oneshot mean say systemd go run the script once and wait make e finish, so systemctl start go show you the result straight. For long-running app you go use another type, and the systemd service types explained one by one show when to use oneshot, simple or exec.

sudo tee /etc/systemd/system/envdemo.service > /dev/null <<'EOF'
[Unit]
Description=envdemo, stand-in for your app

[Service]
Type=oneshot
ExecStart=/opt/envdemo/app.sh
EOF
sudo systemctl daemon-reload
sudo systemctl start envdemo

The start go fail with this message:

Job for envdemo.service failed because the control process exited with error code.

Now read wetin happen:

journalctl -u envdemo -n 5 --no-pager
cat /var/tmp/envdemo/pwd.txt
grep APP_GREETING /var/tmp/envdemo/env.txt

The journal show APP_GREETING is not set, and under am status=1/FAILURE. The pwd.txt file hold only /. The grep print nothing at all. The variable wey you export for this same terminal no reach the service, because the service na child of systemd, no be child of your shell. If another status code show for that line, wetin each systemd exit code mean when unit no wan start go tell you which part fail.

Open env.txt and look the whole list. E short well well: PATH, LANG, INVOCATION_ID, JOURNAL_STREAM and few others. Na the full environment of the process be that. Anything wey no dey that file, the app no fit see.

If your real app get Restart=on-failure, this failure no go stop by itself. systemd go restart am again and again until e hit the start limit, and every restart go fail the same way. How systemd restart policies and start limits work show you how to read that loop. Restart no fit fix missing variable.

How to set environment variables with Environment=

Environment= put the variable inside the unit file itself. If the value get space, put quote round the whole NAME=value.

sudo tee /etc/systemd/system/envdemo.service > /dev/null <<'EOF'
[Unit]
Description=envdemo, stand-in for your app

[Service]
Type=oneshot
Environment="APP_GREETING=How far"
ExecStart=/opt/envdemo/app.sh
EOF
sudo systemctl daemon-reload
sudo systemctl start envdemo
journalctl -u envdemo -n 3 --no-pager

E still fail, but the error don change. The journal now show cannot read .env in /. The variable don reach, so the script pass the first check and fail for the second one. Run grep APP_GREETING /var/tmp/envdemo/env.txt and you go see APP_GREETING=How far.

Run daemon-reload every time you change unit file. If you forget, systemd go still use the old copy wey e load before, and systemctl status envdemo go warn you say the unit file change on disk. If the unit come from one package and no be your own file, put your lines inside drop-in file under /etc/systemd/system/<name>.service.d/ so package upgrade no go wipe them.

Why dotenv no fit find .env: WorkingDirectory=

The Node dotenv package dey look for .env inside the current working directory, wey be process.cwd(). Plenty Python apps dey open relative path like config.yaml or uploads/ the same way. The systemd.exec(5) manual talk am plain: if you no set WorkingDirectory=, e "defaults to the root directory when systemd is running as a system instance". So every relative path dey resolve against /. Your app try open /.env, and that file no exist.

sudo tee /etc/systemd/system/envdemo.service > /dev/null <<'EOF'
[Unit]
Description=envdemo, stand-in for your app

[Service]
Type=oneshot
WorkingDirectory=/opt/envdemo
Environment="APP_GREETING=How far"
ExecStart=/opt/envdemo/app.sh
EOF
sudo systemctl daemon-reload
sudo systemctl start envdemo
journalctl -u envdemo -n 3 --no-pager
cat /var/tmp/envdemo/pwd.txt

This time systemctl start return with no error. The journal show OK: How far, .env found in /opt/envdemo, and pwd.txt now print /opt/envdemo. The other fix na to make the app build absolute path from its own location. Both work. WorkingDirectory= na the one wey no need code change.

If your real unit get PrivateTmp=yes, files wey the service write for /tmp or /var/tmp go land inside private copy wey only that service fit see. How PrivateTmp and ProtectSystem change wetin service fit see explain why your debug file no go show for the normal /var/tmp.

EnvironmentFile=: quotes, export lines and # comments

When the variables plenty, put them for separate file and point the unit to am with EnvironmentFile=. The file format look like shell, but systemd no be shell. It get its own parser. The test below put four kinds of line inside one file so you fit see how systemd read each one.

sudo install -d -m 755 /etc/envdemo
sudo tee /etc/envdemo/envdemo.env > /dev/null <<'EOF'
APP_GREETING="How far, my people"
SINGLE='single quotes'
export EXPORTED_VAR=yes
PORT=8080 # port for the API
EOF
sudo chmod 600 /etc/envdemo/envdemo.env

sudo tee /etc/systemd/system/envdemo.service > /dev/null <<'EOF'
[Unit]
Description=envdemo, stand-in for your app

[Service]
Type=oneshot
WorkingDirectory=/opt/envdemo
Environment=APP_MODE=production
EnvironmentFile=/etc/envdemo/envdemo.env
ExecStart=/opt/envdemo/app.sh
EOF
sudo systemctl daemon-reload
sudo systemctl start envdemo
grep -E '^(APP_|SINGLE|EXPORTED|PORT)' /var/tmp/envdemo/env.txt
journalctl -u envdemo -n 10 --no-pager | grep -i invalid

The grep for env.txt print these four lines:

APP_GREETING=How far, my people
APP_MODE=production
PORT=8080 # port for the API
SINGLE=single quotes

The journal get this line:

envdemo.service: Ignoring invalid environment assignment 'export EXPORTED_VAR=yes': /etc/envdemo/envdemo.env

Here be wetin each result mean.

Quotes. systemd remove both double and single quotes, the same way shell go do am. How far, my people reach the app clean, with the space and the comma inside.

The export line. systemd no sabi the word export. Its parser read everything before the = as the variable name, so the name become export EXPORTED_VAR. Space no dey allowed inside variable name, so systemd skip the whole line and log am. The service still start. The variable just no dey. That na why the failure easy to miss: nothing crash for systemd side, only your app go complain later. Remove export from the file. If you still need to load the same file inside shell script, use set -a; . /etc/envdemo/envdemo.env; set +a, because set -a export every variable wey the file set.

The # comment. systemd treat # as comment only when e be the first thing for the line. Inside value, # na normal character. So PORT hold the full text 8080 # port for the API, and app wey try read that as number go fail. Put every comment for its own line.

Two more rules from the manual. If the same name dey for both Environment= and the file, the value from EnvironmentFile= win. If the file no exist, the service no go start at all, unless you write the path with - in front, like EnvironmentFile=-/etc/envdemo/envdemo.env.

Which PATH a systemd service dey get?

Compare the PATH wey the service see with your own:

grep '^PATH=' /var/tmp/envdemo/env.txt
echo "$PATH"
systemctl show-environment

The service PATH na fixed list wey systemd set. E start with /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin. For cloud image wey get snapd, /snap/bin fit join am, because snapd ship one small generator program wey add am. systemctl show-environment print the same base environment wey systemd give every service, so na there you go check am for any box.

Wetin no dey there matter pass. Folders like ~/.local/bin, ~/.cargo/bin or ~/.nvm/versions/node/.../bin no dey, because na your ~/.bashrc or ~/.profile add them, and the service no read those files. If you install Node with nvm and write ExecStart=node server.js, systemd no go find node, and the unit fail with 203/EXEC. Run command -v node for your terminal, then put that full path for ExecStart=.

Does /etc/environment reach a systemd service?

Test am. Add one line, run the service again, and look for the variable:

echo 'FROM_ETC_ENVIRONMENT=yes' | sudo tee -a /etc/environment > /dev/null
sudo systemctl start envdemo
grep FROM_ETC_ENVIRONMENT /var/tmp/envdemo/env.txt
sudo sed -i '/^FROM_ETC_ENVIRONMENT=/d' /etc/environment

The grep print nothing. The variable no reach the service. Na pam_env dey read /etc/environment. pam_env na one module of PAM (pluggable authentication modules), and PAM run when person log in through SSH, su or sudo. System service no pass through PAM unless the unit set PAMName=, so nobody read that file for am. The last command remove the test line, so your box go dey clean.

Wetin systemctl show -p Environment dey print

sudo systemctl show -p Environment envdemo

E print Environment=APP_MODE=production. Only the Environment= lines from the unit dey show here. The values inside the file wey EnvironmentFile= point to no dey for this output. systemd keep only the file path, for another property called EnvironmentFiles, and e read the file again every time the service start. If you wan see the full final environment of service wey dey run, find the process ID with systemctl show -p MainPID myapp, then run sudo cat /proc/<PID>/environ | tr '\0' '\n'.

Why environment variables no good for secrets

Na here many people dey put database password or PAYSTACK_SECRET_KEY inside Environment=. The systemd.exec(5) manual warn against am directly:

Environment variables set for a unit are exposed to unprivileged clients via D-Bus IPC, and generally not understood as being data that requires protection.

D-Bus na the message system wey programs dey use talk to systemd. The systemctl show command you run before na D-Bus request. The manual talk say normal users without root fit ask for the same property. Environment variables also dey pass down to every child process wey your app start. They fit show for crash report, debug page or log line wey print the whole environment. EnvironmentFile= with mode 600 protect the file for disk, but once the value enter the process environment, e still get the same exposure.

LoadCredential=: the better way to pass secrets

LoadCredential= give the secret to the service as file, no be as environment variable. First, confirm say your systemd new reach. The feature come for systemd 247.

systemctl --version | head -n 1

As of October 2026, Ubuntu 24.04 dey ship systemd 255, so e dey there. Now create the secret as file wey only root fit read, and point the unit to am:

sudo install -d -m 700 /etc/envdemo/creds
sudo sh -c 'umask 077; printf "%s" "sk_test_replace_me" > /etc/envdemo/creds/paystack_secret'

sudo tee /etc/systemd/system/envdemo.service > /dev/null <<'EOF'
[Unit]
Description=envdemo, stand-in for your app

[Service]
Type=oneshot
WorkingDirectory=/opt/envdemo
Environment=APP_MODE=production
EnvironmentFile=/etc/envdemo/envdemo.env
LoadCredential=paystack_secret:/etc/envdemo/creds/paystack_secret
ExecStart=/opt/envdemo/app.sh
EOF
sudo systemctl daemon-reload
sudo systemctl start envdemo
journalctl -u envdemo -n 3 --no-pager
grep CREDENTIALS_DIRECTORY /var/tmp/envdemo/env.txt
grep -c sk_test /var/tmp/envdemo/env.txt

The journal show secret is 18 bytes, so the app read the secret. env.txt hold CREDENTIALS_DIRECTORY=/run/credentials/envdemo.service, wey be only the folder path. The last grep print 0, because the secret value no dey anywhere inside the environment.

Here be how e work. Before systemd start the service, systemd (wey dey run as root) read the source file. E copy am into private folder under /run/credentials/, wey only the service user fit read, then e set $CREDENTIALS_DIRECTORY to point there. Because of that, the source file fit stay root-only with mode 600, even when the service run as another user. When the service stop, systemd remove the folder. Inside your real app, read the file like this. For Node: fs.readFileSync(path.join(process.env.CREDENTIALS_DIRECTORY, 'paystack_secret'), 'utf8'). For Python: open(os.path.join(os.environ['CREDENTIALS_DIRECTORY'], 'paystack_secret')).read().

LoadCredential= work best with service wey run as its own user, no be root, and how to set up least-privilege users on your VPS show how to create that user. When the secrets plenty and many servers need them, one file per secret go tire you. That na when a self-hosted secrets manager make sense.

Clean up the test service

sudo rm /etc/systemd/system/envdemo.service
sudo systemctl daemon-reload
sudo systemctl reset-failed
sudo rm -r /opt/envdemo /etc/envdemo /var/tmp/envdemo

reset-failed clear the old failed record wey the first runs leave. The /etc/environment line don already comot for the earlier section.

FAQ

Why my systemd service no dey see the variable wey I put for ~/.bashrc?

systemd start the service from PID 1, no be from your login shell, so ~/.bashrc, ~/.profile and anything you export for terminal no dey run for am. Put the variable inside the unit with Environment=NAME=value, or inside one file wey EnvironmentFile= point to. Then run sudo systemctl daemon-reload and restart the service.

Why my app no fit find .env when e run as systemd service?

Without WorkingDirectory=, a system service start inside /, so relative path like .env become /.env. The Node dotenv package look for .env inside the current working directory, so e no see your file. Add WorkingDirectory=/path/to/your/app to the [Service] section, or make the app use absolute path.

Fit I use export inside EnvironmentFile?

No. systemd read export NAME=value as variable name export NAME, wey no valid, so e skip the line and log Ignoring invalid environment assignment for the journal. The service still start, but the variable go dey missing. Also, # only start comment at the beginning of line, so comment wey follow value on the same line go become part of the value.

Why node or python dey work for terminal but the service fail with 203/EXEC?

systemd give service fixed PATH wey start with /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin. Tools wey nvm, pyenv or ~/.local/bin provide no dey inside, because na your shell profile add them to PATH. Run command -v node for terminal and put that full path for ExecStart=.

Where I suppose put password or API key for systemd service?

No put am inside Environment=. The systemd.exec manual warn say environment variables set for unit dey exposed to unprivileged clients through D-Bus. Store the secret as root-only file and use LoadCredential=name:/path/to/file. Your app then read am from $CREDENTIALS_DIRECTORY/name. This need systemd 247 or newer, and systemctl --version go tell you which one you get.