---
title: "systemd hardening: taking a Python service from 9.6 to 1.7"
description: "How to harden a Python systemd service and bring the systemd-analyze score from 9.6 (UNSAFE) to 1.7 (EXPOSED) without breaking features. IPAddressDeny + IPAddressAllow trade-offs, UMask=0077 trap, reusable recipe."
url: "https://www.arleo.eu/en/posts/hardening-systemd-mcp/"
language: "en"
datePublished: "2026-05-09T13:02:29+02:00"
dateModified: "2026-05-29T19:40:36+02:00"
tags: ["systemd","hardening","security","linux","infrastructure"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# systemd hardening: taking a Python service from 9.6 to 1.7
How to harden a Python systemd service and bring the systemd-analyze score from 9.6 (UNSAFE) to 1.7 (EXPOSED) without breaking features. IPAddressDeny + IPAddressAllow trade-offs, UMask=0077 trap, reusable recipe.



## TL;DR

`systemd-analyze security` is an underused tool. It scans your unit files and computes an exposure score from **0.0 (UNSAFE)** to **10.0 (PERFECT)**. Custom Python services often score around **9.6** by default — that's bad.

I took my `hugo-mcp` service (FastAPI exposing 7 MCP tools) from **9.6 → 1.7** without breaking a single feature. Here are the directives that actually matter, and the ones that are traps.

## Initial score

<!--more-->
<!-- mermaid-source:Z3JhcGggTFIKICAgIEFbImh1Z28tbWNwLnNlcnZpY2U8YnIvPlNjb3JlOiA5LjYgVU5TQUZFIl0gLS0+IEJbIlByb3RlY3RTeXN0ZW09c3RyaWN0PGJyLz5Qcml2YXRlVG1wIMK3IFByaXZhdGVEZXZpY2VzIl0KICAgIEEgLS0+IENbIkNhcGFiaWxpdHlCb3VuZGluZ1NldD3iiIU8YnIvPk5vTmV3UHJpdmlsZWdlcz10cnVlIl0KICAgIEEgLS0+IERbIklQQWRkcmVzc0Rlbnk9YW55PGJyLz4rIENGIHdoaXRlbGlzdCJdCiAgICBBIC0tPiBFWyJTeXN0ZW1DYWxsRmlsdGVyPUBzeXN0ZW0tc2VydmljZTxici8+TWVtb3J5RGVueVdyaXRlRXhlY3V0ZT10cnVlIl0KICAgIEIgJiBDICYgRCAmIEUgLS0+IEZbIlNjb3JlOiAxLjcgRVhQT1NFRCJdCiAgICBzdHlsZSBBIGZpbGw6IzkyMmIyMSxjb2xvcjojZmZmCiAgICBzdHlsZSBGIGZpbGw6IzFlODQ0OSxjb2xvcjojZmZm -->
{{< mermaid-svg hash="2cea35c6" >}}

```bash
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 9.6 UNSAFE 😨
```

9.6 out of 10. The service runs root-equivalent, can `setuid()`, can allocate `RWX` memory, sees all system processes, can write everywhere, can make arbitrary syscalls.

For an MCP that edits Markdown content and triggers a Hugo build, that's massive overkill.

## The directives that actually matter

### 1. System isolation

```ini
[Service]
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
```

Impact: the service only sees `/proc/<its-pid>/`, can't touch the kernel, cgroups, or system clock.

### 2. Linux capabilities

```ini
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=true
```

The service runs with **zero** capabilities. No `CAP_NET_BIND_SERVICE`, no `CAP_SYS_ADMIN`. If you listen on a port < 1024, use socket activation or `setcap` on the binary — not an ambient capability.

### 3. Filesystem write whitelist

```ini
ReadWritePaths=/home/jm/hugo-mcp /home/jm/hugo-site
ReadOnlyPaths=/etc
PrivateMounts=true
```

The service can write to ONLY 2 specific folders. Any other `open(O_WRONLY)` returns `EROFS`. Even if someone finds an RCE in FastAPI, the attacker can't modify `/etc/passwd` or `/usr/local/bin/`.

### 4. Network egress whitelist

```ini
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
IPAddressAllow=192.168.122.1/32
# Cloudflare API (cache purge)
IPAddressAllow=104.16.0.0/12
IPAddressAllow=2606:4700::/32
```

The most powerful and trickiest directive. The service can only reach loopback, libvirt bridge, and Cloudflare ranges.

**Trap #1**: if you forget external ranges your service calls (Cloudflare API, GitHub, your log broker), you'll get **silent timeouts** that are hard to debug. I spent ~2h figuring out why `purge_cache` was timing out at 5s when manual SSH worked: `IPAddressDeny=any` was active without Cloudflare whitelisting.

**Trap #2**: `IPAddressAllow` only supports IPv4/IPv6 ranges, not hostnames. So if Cloudflare changes its ranges, you have to update the unit file.

### 5. Syscall filter

```ini
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
```

`@system-service` is a predefined set of "reasonable" syscalls for a service. Blocks anything niche (`mount`, `swapon`, `reboot`...).

### 6. Bonus

```ini
LockPersonality=true              # blocks setpersonality()
RestrictRealtime=true             # no realtime scheduling
RestrictSUIDSGID=true             # no setuid file creation
RemoveIPC=true                    # IPC cleanup on stop
RestrictNamespaces=true           # no unshare(CLONE_NEWNS)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true       # blocks mmap PROT_WRITE|PROT_EXEC
UMask=0077                        # files created without group/world
```

`MemoryDenyWriteExecute=true` is interesting: it blocks RWX mmap, which breaks most JIT-style shellcodes. For a pure Python service (no JIT), zero functional impact.

## The `UMask=0077` trap

This directive creates ALL files as `0600` (read/write **only** for the user). Innocent-looking.

Except my `hugo-mcp` service writes to `public/` (Hugo build folder), and nginx (which serves these files) runs as a different user. Result:

```
$ ls -la public/
-rw------- 1 hugo-mcp hugo-mcp  4823 May  9 14:23 index.html
```

nginx can no longer read `index.html`. The site returns **403 Forbidden** on everything after each rebuild.

**Fix**: add `chmod -R o+rX public/` in `deploy.sh` after each build:

```bash
hugo --minify
chmod -R o+rX public/   # ← essential after UMask=0077
rsync -a public/ /var/www/hugo/
```

## Final score

```bash
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 1.7 EXPOSED 😱
```

From **9.6 UNSAFE** to **1.7 EXPOSED**. The "EXPOSED" label still sounds threatening, but `systemd-analyze` just considers any service that listens on a port to be exposed by nature. Going lower means socket activation + non-privileged `User=` + full namespace isolation, which no longer makes sense for an active network service.

## What `systemd-analyze` doesn't tell you

The systemd score only covers **kernel isolation**. It says nothing about:

- Application security (auth, input validation, rate limiting...)
- Secrets in `/etc/secrets/` (readable by root via another path)
- Logs that might leak tokens
- Python deps (CVE in a cached lib)

It's just **one layer** in a defense-in-depth strategy. But it's a cheap layer that hardens the default posture significantly.

## Recipe applicable to any Python service

If you have a Python systemd service, here's a drop-in you can adapt in 5 minutes:

```bash
sudo systemctl edit my-service
```

```ini
[Service]
# Isolation
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectProc=invisible
ProcSubset=pid

# Capabilities
CapabilityBoundingSet=
AmbientCapabilities=

# Filesystem
ReadWritePaths=/home/user/data
PrivateMounts=true

# Network
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
# Add the external ranges you need

# Syscalls
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

# Bonus
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true
UMask=0077
```

Then:

```bash
sudo systemctl daemon-reload
sudo systemctl restart my-service
sudo systemd-analyze security my-service
```

If the service refuses to start, check `journalctl -u my-service -n 50` — usually it's `IPAddressDeny=any` blocking a forgotten external API call.

## Conclusion

systemd hardening is an underrated ops discipline. 30 minutes of investment to go from 9.6 to 1.7 on a service. Almost always worth it.

For the ongoing MCP security sprint, this is just the foundation. Still to come: application-level rate limiting, structured JSON audit logs, strict Pydantic validation, token rotation, internal TLS. But none of those layers would make sense on a `9.6 UNSAFE` service.

You harden the OS first. The code comes after.


## Tags

- systemd
- hardening
- security
- linux
- infrastructure

## Categories

- infrastructure
