Two practical issues come up immediately when you move a real application into a systemd service. The first is that relative file path problem. The code works fine when you run it from your terminal but it stops finding its own files when systemd runs it. The second is that your application needs configuration: server addresses, API keys, database credentials. But hardcoding those values into your service file is both inflexible and a security risk. This artilce is about how to solve these two.
If you want a concrete working example to follow along with, use the script from examples/env-service/app_with_config.py in the GitHub repository. It is a Python service that reads all its configuration from environment variables — both sensitive secrets (via EnvironmentFile) and non-sensitive settings (via inline Environment directives).
The Working Directory Problem
When you run a Python script from your terminal, your shell sets the working directory to wherever you currently are. If you're in /home/pi/my-app/ and your script opens config.json without a full path, Python looks for config.json in /home/pi/my-app/ and finds it. This is so natural that most developers don't even think about it.
But when systemd runs your service things are different. The working directory is not set to your project folder. By default it's /, the root of the filesystem. That same open("config.json") call will look for /config.json. I don't need to tell you what happens now. It fails with a FileNotFoundError, and crash your service before it has done anything useful.
The fix is the WorkingDirectory directive in the [Service] section:
WorkingDirectory=/home/pi/my-app
When you set this, any relative file paths your application uses will resolve relative to that directory, just as they would if you ran the script from there in your terminal. While you're at it, specify which user the service should run as. By default, systemd runs services as root. This is more privilege than most applications need and it can create a security risk if the application is ever compromised.
User=pi
Group=pi
WorkingDirectory=/home/pi/my-app
So running as a non-root user is the principle of least privilege. It gives a process only the permissions it actually needs. If your MQTT client or sensor pipeline has a bug that an attacker could exploit, running as pi means the attacker gets the capabilities of the pi user and not root access to the entire system. However, the tradeoff is that you need to ensure the pi user has read/write permission to the files your application needs, but that's almost always already the case for files in /home/pi/.
To find your 'User' and 'Group', run the command id. This returns the active User ID (UID), Primary Group ID (GID), and all Supplementary Groups.
Inline Environment Variables
The simplest way to pass configuration to a service is with the Environment directive. It sets individual environment variables directly inside the service file:
[Service]
Environment="APP_PORT=8080"
Environment="LOG_LEVEL=INFO"
Environment="BROKER_HOST=localhost"
Your Python code reads these with os.environ.get("APP_PORT") or os.getenv("APP_PORT", "8080") (the second argument being a default if the variable isn't set). This approach is fine for non-sensitive configuration. For example, server addresses, port numbers, feature flags, log verbosity. It makes debugging easy because it is clear, explicit, and the values are visible in systemctl status output.
However, you should never use Environment for secrets. API keys, database passwords and authentication tokens are sensitive values that should never be visible in service file contents, which can be read by anyone with filesystem access. For secrets, what you need is an environment file.
Environment Files: The Right Way to Handle Secrets
An EnvironmentFile is a separate text file, usually stored in /etc/ with restricted permissions. It contains key-value pairs which systemd loads and injects into your service's environment before starting it. The service file references the path to this file rather than containing the values themselves.
The good practice is to create the directory and the environment file first:
sudo mkdir -p /etc/my-app
sudo nano /etc/my-app/config.env
The file format is simple. It has a one variable per line, like the same KEY=VALUE syntax we use in a shell script. Comments starting with # are allowed and considered good practice. Always use short but clear comments:
# /etc/my-app/config.env
# Server configuration
BROKER_HOST=mqtt.example.com
BROKER_PORT=8883
# Authentication. Keep this file private
API_KEY=secret-api-put-here
DB_PASSWORD=database-password
Now restrict the file's permissions so only root can read it. Most people skips this step. This is the crucial step that makes the whole thing secure:
sudo chmod 600 /etc/my-app/config.env
sudo chown root:root /etc/my-app/config.env
Finally, reference the file in your service with the EnvironmentFile directive:
EnvironmentFile=/etc/my-app/config.env
When systemd starts your service, it reads this file and sets all the listed variables in the service's environment. Our Python code reads them with os.environ.get("API_KEY") exactly as it would read any other environment variable and doesn't care where they came from.
Here, is a small but useful syntax trick: if you prefix the path with a minus sign (-), systemd will ignore a missing file rather than failing to start the service. I always use this in development where the config file does not exist yet:
EnvironmentFile=-/etc/my-app/config.env
The Complete Service File So Far
Combining everything from the last three articles, here's the service file in its current form:
[Unit]
Description=My Application
After=network.target
Wants=network.target
[Service]
# Run as a non-root user for security
User=pi
Group=pi
# Set the working directory so relative paths work correctly
WorkingDirectory=/home/pi/my-app
# Load secrets and configuration from a separate, restricted file
EnvironmentFile=/etc/my-app/config.env
# Non-sensitive configuration can go inline
Environment="LOG_LEVEL=INFO"
# The command that launches your application
ExecStart=/usr/bin/python3 /home/pi/my-app/main.py
# Restart automatically on unexpected crashes
Restart=on-failure
RestartSec=5
# Give up after 5 crashes within 60 seconds signals a real problem
StartLimitIntervalSec=60
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
After making changes, reload and restart as always:
sudo systemctl daemon-reload
sudo systemctl restart my-app.service
How to Access Environment Variables in Python
Here is how your application can read the environment variables set by the service file and environment file:
import os
# Read from EnvironmentFile (secrets)
broker_host = os.environ.get("BROKER_HOST", "localhost")
broker_port = int(os.environ.get("BROKER_PORT", "1883"))
api_key = os.environ.get("API_KEY")
# Read from inline Environment directive
log_level = os.environ.get("LOG_LEVEL", "INFO")
if api_key is None:
# Using print() works fine since systemd captures stdout to the journal
print("ERROR: API_KEY is not set. Check /etc/my-app/config.env")
raise SystemExit(1)
print(f"Connecting to {broker_host}:{broker_port} with log level {log_level}")
# And your code ...
When systemd captures that error from stdout and logs it to the journal, you'll see exactly why the service failed. This is why I always use the pattern of failing fast with a clear, specific error message if a required variable is missing. It is much better than a cryptic exception from deep inside a library.
In the next article, we will look at how to read, filter, and manage logs, and how to configure the journal so it does not fill or wear out an SD card. This is 101 on embedded devices with limited storage!


Top comments (0)