Fix a Gunicorn systemd Service That Fails to Start

Fix a Gunicorn service for Django that systemd cannot start: status codes 203/EXEC, 200/CHDIR, 217/USER, environment files, socket directories, and restart loops.

Problem statement

After creating or editing a systemd unit for Gunicorn, the service will not start:

$ sudo systemctl start gunicorn
Job for gunicorn.service failed because the control process exited with error code.
See "systemctl status gunicorn.service" and "journalctl -xeu gunicorn.service" for details.

systemctl status shows a line such as:

Process: 4121 ExecStart=/srv/myapp/venv/bin/gunicorn ... (code=exited, status=203/EXEC)

Unlike Python tracebacks, systemd status codes such as 203/EXEC or 200/CHDIR describe failures that happen before Gunicorn runs any Python code: systemd could not set up the process. Other failures happen after Gunicorn starts (exit status 1 or 3 with a Python error). Reading which kind you have saves a lot of guesswork.

How systemd starts a serviceUnit fileuser, paths, envSetupnamespaces, env fileExecStartgunicorn binaryApplicationruns or exitsStatus codes such as 203/EXEC report failures before your code ever runs.

Quick answer

  1. Run systemctl status gunicorn --no-pager -l and journalctl -u gunicorn -n 50 --no-pager.
  2. Match the status:
    • 203/EXEC: ExecStart path wrong, not executable, or its interpreter missing
    • 200/CHDIR: WorkingDirectory does not exist or is not accessible to the user
    • 217/USER: the User= or Group= does not exist
    • 226/NAMESPACE or ReadWritePaths errors: sandboxing options reference missing paths
    • failure mentioning EnvironmentFile: the environment file is missing
    • status=1 or status=3 with a traceback: Python or Gunicorn error at startup
  3. After editing the unit, run sudo systemctl daemon-reload before restarting.
  4. Test the exact ExecStart command manually as the service user.
  5. Use RuntimeDirectory= for socket directories under /run.

Step-by-step solution

1. Collect the details

systemctl status gunicorn --no-pager -l
sudo journalctl -u gunicorn -n 50 --no-pager
systemctl cat gunicorn
the status code names the stepActive: failed (Result:exit-code)Process: 4112 ExecStart=/srv/app/venv/bin/gunicorn…(code=exited,status=203/EXEC)1231The process exited; look at the status.2Check this exact path exists.3systemd could not execute the binary.Codes in the 200s come from systemd, before Gunicorn ran at alldjango-deployment.com
A plain status=1 means Gunicorn ran and your app failed instead.

systemctl cat prints the unit exactly as systemd loaded it, including drop-in overrides from /etc/systemd/system/gunicorn.service.d/. Edits to a unit file are ignored until you run:

sudo systemctl daemon-reload

A frequent "nothing changes" moment is editing the unit and restarting without reloading; systemctl status then warns that the unit file changed on disk.

2. status=203/EXEC

status codes, decodedCause203/EXECExecStart path wrong or not executable200/CHDIRWorkingDirectory missing217/USERUser= does not exist226/NAMESPACE!sandboxing path missing1 or 3✕Gunicorn or Django error: read the journaljournalctl -u gunicorn -b shows the attempt with contextdjango-deployment.com
The sections below cover each status in turn.

systemd could not execute the program in ExecStart.

ls -l /srv/myapp/venv/bin/gunicorn
head -1 /srv/myapp/venv/bin/gunicorn
sudo -u myapp /srv/myapp/venv/bin/gunicorn --version

Causes:

  • Relative or wrong path. ExecStart needs an absolute path to the executable. ExecStart=gunicorn ... relies on systemd's limited search path and fails when Gunicorn lives in a virtualenv.
  • Missing interpreter. The script's shebang points at a Python that no longer exists (the virtualenv was created with a Python that was removed or upgraded). Recreate the virtualenv.
  • Not executable or on a noexec mount. Check mount | grep noexec for the filesystem holding the virtualenv.
  • SELinux denying execution from a home directory on RHEL-family systems; check ausearch -m avc -ts recent.

3. status=200/CHDIR

sudo -u myapp test -d /srv/myapp/app && echo "exists and accessible"
namei -l /srv/myapp/app

WorkingDirectory must exist and be traversable by the service user. A path under a home directory with mode 750 fails for another user, and a deploy that replaced a symlink target can leave WorkingDirectory pointing at a removed directory.

4. status=217/USER

id myapp
getent group www-data

The user or group in User= and Group= does not exist. Create the system user:

sudo adduser --system --group --home /srv/myapp myapp

5. EnvironmentFile failures

If the environment file is missing, systemd refuses to start the service and the journal names the file:

gunicorn.service: Failed to load environment files: No such file or directory

Fix the path or create the file. A leading - (EnvironmentFile=-/etc/myapp/env) makes a missing file acceptable, which hides mistakes; use it only for genuinely optional files.

Also check the file format: KEY=value per line, no export keyword. Lines systemd cannot parse are logged and ignored, which can later surface as missing variables in Django.

6. Socket directory problems

With a Unix socket bind:

[ERROR] Retrying in 1 second.
[ERROR] Can't connect to /run/gunicorn/gunicorn.sock
PermissionError: [Errno 13] Permission denied: '/run/gunicorn'
/run is emptied at every bootmkdir /run/gunicorn by hand✕works until the next reboot✕then bind fails: no such directory✕wrong owner after recreationRuntimeDirectory=gunicorn✓created at every start✓owned by User= and Group=✓removed when the service stopsWorks after systemctl start but fails at boot is this, almost alwaysdjango-deployment.com
RuntimeDirectoryMode controls who else may enter it.

/run is a tmpfs recreated on boot, so directories created manually there disappear after a reboot, and the service user cannot create them itself. Let systemd create it:

[Service]
User=myapp
Group=www-data
RuntimeDirectory=gunicorn
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application \
    --bind unix:/run/gunicorn/gunicorn.sock

RuntimeDirectory=gunicorn creates /run/gunicorn owned by User and Group each time the service starts. A service that worked until the first reboot is the classic sign of a missing RuntimeDirectory.

If you use socket activation with a gunicorn.socket unit instead, the socket unit owns the path; check it with systemctl status gunicorn.socket.

7. Sandboxing options

Hardening options can prevent startup:

gunicorn.service: Failed to set up mount namespacing: /srv/myapp/media: No such file or directory
gunicorn.service: Failed at step NAMESPACE spawning /srv/myapp/venv/bin/gunicorn: No such file or directory

ReadWritePaths=, ReadOnlyPaths=, and similar options require the listed paths to exist (prefix a path with - to make it optional). Create the directories or correct the paths.

8. Gunicorn starts, then exits

If systemd starts Gunicorn but it exits with status 1 or 3, the journal contains Gunicorn and Python output:

gunicorn[5012]: ModuleNotFoundError: No module named 'config'
gunicorn[5012]: [ERROR] Worker (pid:5013) exited with code 3
gunicorn[5012]: [ERROR] Shutting down: Master
gunicorn[5012]: [ERROR] Reason: Worker failed to boot.

This is an application problem, not a systemd problem. Reproduce it with the same user, directory, and environment:

sudo -u myapp bash -c 'cd /srv/myapp/app && set -a && . /etc/myapp/env && set +a && \
  /srv/myapp/venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8001'

Common causes: wrong module path, missing environment variables, database unreachable at import time, or a syntax error in settings.

9. Restart loops and start limits

With Restart=always or Restart=on-failure, a failing service restarts repeatedly until systemd's start rate limit is reached:

gunicorn.service: Start request repeated too quickly.
gunicorn.service: Failed with result 'exit-code'.

After fixing the cause, clear the failed state:

sudo systemctl reset-failed gunicorn
sudo systemctl start gunicorn

A RestartSec=2 or higher avoids tight loops that flood the journal.

10. A known-good unit for reference

[Unit]
Description=Gunicorn for myapp
After=network.target

[Service]
Type=notify
User=myapp
Group=www-data
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
RuntimeDirectory=gunicorn
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application \
    --bind unix:/run/gunicorn/gunicorn.sock \
    --workers 3 \
    --access-logfile - \
    --error-logfile -
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=30
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
a unit that starts every timeUser= and Group= existgroup shared with NginxWorkingDirectory= existsthe project rootEnvironmentFile= without a dashfails loudly if missingExecStart= full venv pathno activate neededRuntimeDirectory= for the socketsurvives rebootssystemd-analyze verify catches typos before a deploydjango-deployment.com
Enable the unit too, or it will not start after a reboot.

Gunicorn supports systemd's notify protocol, so Type=notify lets systemd know when Gunicorn is actually ready. If your Gunicorn version or environment does not send the notification, systemd times out the start; switch to Type=simple in that case.

KillMode=mixed sends SIGTERM to the master only, letting Gunicorn shut workers down gracefully, then kills any leftovers after the timeout.

11. Verify

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn
systemctl is-active gunicorn
ls -l /run/gunicorn/gunicorn.sock
curl -sI --unix-socket /run/gunicorn/gunicorn.sock http://localhost/ -H "Host: www.example.com" | head -1
sudo reboot   # optional: confirm it comes back after a reboot

Verification check:

  • is-active prints active
  • the socket exists with the expected owner and group
  • a request through the socket returns an HTTP status
  • after a reboot, the service starts automatically

Explanation

systemd starts a service in stages: resolve the user and group, set up namespaces and sandboxing, load environment files, change into the working directory, and execute the command. Each stage has its own failure code, reported before your application runs. Once ExecStart executes, failures come from Gunicorn and Django and appear as tracebacks in the journal. Distinguishing the two tells you whether to fix the unit file or the application.

Edge cases / notes

Works with systemctl start, fails at boot

Dependencies may not be ready at boot, for example a network filesystem or a database on the same host. Add After= and Wants= for the required units, such as postgresql.service or network-online.target.

User services

Units under ~/.config/systemd/user/ run in the user's service manager, which stops when the user logs out unless lingering is enabled (loginctl enable-linger). Production services belong in /etc/systemd/system/.

Validating units before deploying them

systemd ships a linter that catches many unit file mistakes before you restart a production service:

sudo systemd-analyze verify /etc/systemd/system/gunicorn.service

It reports unknown directives, missing executables, and invalid values. Combine it with a dry run of the command as the service user, and you catch most failure codes from this guide without taking the running service down.

For hardening, systemd-analyze security gunicorn.service scores the unit's sandboxing. Add options gradually and restart after each one, so if the service stops starting you know which option caused it.

Overrides instead of editing packaged units

If a unit file comes from a package or a configuration management tool, put local changes in a drop-in:

sudo systemctl edit gunicorn

This opens an override file under /etc/systemd/system/gunicorn.service.d/override.conf. To replace list-type settings such as ExecStart, clear them first:

[Service]
ExecStart=
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application --bind unix:/run/gunicorn/gunicorn.sock --workers 4

Without the empty ExecStart= line, systemd reports that a service has more than one ExecStart= setting and refuses to start it (unless the type is oneshot).

Watching a start attempt live

Open the journal in one terminal and start the service in another:

sudo journalctl -u gunicorn -f
sudo systemctl start gunicorn

You see every message in order, including messages from systemd itself about each setup stage, which is often clearer than the summarized status output after the fact.

Common mistakes

  • Editing the unit file without systemctl daemon-reload.
  • Creating the socket directory manually under /run instead of using RuntimeDirectory=.
  • Using a relative path in ExecStart.
  • Adding - to EnvironmentFile= and hiding a wrong path.

Next steps

FAQ

What does status=203/EXEC mean for a Gunicorn service?

systemd could not execute the ExecStart program. The path is wrong, the file is not executable, or its Python interpreter no longer exists. Use the absolute path to Gunicorn in the virtualenv.

Why does my Gunicorn socket directory disappear after reboot?

/run is a temporary filesystem recreated at boot. Use RuntimeDirectory= in the unit so systemd creates the directory with correct ownership each time the service starts.

Do I need daemon-reload after changing a systemd unit?

Yes. systemd keeps the loaded unit in memory until you run sudo systemctl daemon-reload. Changes to environment files only need a service restart.

How do I fix "Start request repeated too quickly"?

Fix the underlying failure from the journal, then run sudo systemctl reset-failed gunicorn to clear the rate limit and start the service again.

Keep going