Exploring the roots and future of Internet Relay Chat

systemd Units for IRC: Service Files, Sandboxing, and Logging

For years, I managed IRC daemons the old way. Start them in a screen session. Write a cron job that checks if the process is running every five minutes and restarts it if not. Redirect stdout to a log file and hope logrotate handles the rest. It worked, in the same way that duct tape on a pipe works — until it doesn't. When I migrated our network to Debian 9 in 2017 and started writing proper systemd units, the operational improvement was immediate. Auto-restart on crash took seconds instead of five minutes. Logs were queryable. Resource limits were enforceable. I should have done it years earlier.

Why systemd for IRC Services

If your server runs Linux with systemd (which is most of them since 2015), you should use systemd to manage your IRC processes. The benefits over manual process management:

  • Automatic restart: If the IRCd crashes, systemd restarts it within seconds. No cron polling.
  • Boot startup: Enable the unit and it starts on boot. No rc.local hacks.
  • Resource control: Limit memory, CPU, and file descriptors per service.
  • Security sandboxing: Restrict filesystem access, network capabilities, and system calls.
  • Structured logging: journald captures all output with timestamps, filterable by unit.
  • Dependency ordering: Start services in the right order (network up → IRCd → services → bots).

Every IRC daemon I've seen can run under systemd. Some ship their own unit files. Most don't, or the ones they ship are minimal. Writing your own takes 10 minutes and gives you full control.

IRCd Service Units

Here's a production-grade unit file for InspIRCd:

# /etc/systemd/system/inspircd.service
[Unit]
Description=InspIRCd - Internet Relay Chat Daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=forking
User=inspircd
Group=inspircd
PIDFile=/run/inspircd/inspircd.pid
RuntimeDirectory=inspircd
RuntimeDirectoryMode=0750

ExecStart=/opt/inspircd/bin/inspircd --config /opt/inspircd/conf/inspircd.conf
ExecReload=/bin/kill -HUP $MAINPID

Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5

# Resource limits
LimitNOFILE=65536
LimitCORE=0

# Security
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/inspircd/data /opt/inspircd/logs /run/inspircd
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true

[Install]
WantedBy=multi-user.target

Key decisions in this unit:

Type=forking — InspIRCd forks to background by default. systemd waits for the PID file to appear. If your IRCd has a --nofork or --foreground option, use Type=simple instead — it's simpler and avoids PID file race conditions.

LimitNOFILE=65536 — An IRC server needs one file descriptor per connected client, plus descriptors for the listener sockets, log files, and server-to-server links. The default limit (1024) is too low for any network above a few hundred users. Set it high. The kernel allocates descriptors lazily, so a high limit costs nothing until used.

For UnrealIRCd, the unit is similar but adapted for its directory structure:

# /etc/systemd/system/unrealircd.service
[Unit]
Description=UnrealIRCd - Internet Relay Chat Daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=forking
User=unrealircd
Group=unrealircd
WorkingDirectory=/home/unrealircd/unrealircd
PIDFile=/home/unrealircd/unrealircd/data/unrealircd.pid

ExecStart=/home/unrealircd/unrealircd/unrealircd start
ExecStop=/home/unrealircd/unrealircd/unrealircd stop
ExecReload=/home/unrealircd/unrealircd/unrealircd rehash

Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5

LimitNOFILE=65536
LimitCORE=0

NoNewPrivileges=true
ProtectHome=read-only
ReadWritePaths=/home/unrealircd/unrealircd/data /home/unrealircd/unrealircd/logs /home/unrealircd/unrealircd/tmp
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target
Tip: Use Type=simple with --foreground whenever possible. It eliminates PID file issues entirely. InspIRCd 4 supports --nofork. UnrealIRCd 6 supports running in the foreground when started without the start argument. Check your IRCd's documentation.

IRC Services (Anope/Atheme) Units

IRC services need to start after the IRCd is running, because they connect to it. Use the After and Requires directives:

# /etc/systemd/system/anope.service
[Unit]
Description=Anope IRC Services
After=inspircd.service
Requires=inspircd.service

[Service]
Type=forking
User=anope
Group=anope
WorkingDirectory=/opt/anope
PIDFile=/opt/anope/data/services.pid

ExecStart=/opt/anope/bin/services --config /opt/anope/conf/services.conf
ExecReload=/bin/kill -HUP $MAINPID

Restart=on-failure
RestartSec=10
StartLimitIntervalSec=120
StartLimitBurst=3

LimitNOFILE=4096

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/anope/data /opt/anope/logs
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Notice RestartSec=10 instead of 5. If the IRCd is temporarily down (during a rehash or brief restart), services will fail to connect and crash. A longer delay gives the IRCd time to come back before services retry. I also set StartLimitBurst=3 lower — if services fail to connect three times in two minutes, something is genuinely wrong and you need to investigate rather than keep retrying.

For Atheme:

# /etc/systemd/system/atheme.service
[Unit]
Description=Atheme IRC Services
After=inspircd.service
Requires=inspircd.service

[Service]
Type=forking
User=atheme
Group=atheme
WorkingDirectory=/opt/atheme
PIDFile=/opt/atheme/var/atheme.pid

ExecStart=/opt/atheme/bin/atheme-services -c /opt/atheme/etc/atheme.conf -p /opt/atheme/var/atheme.pid

Restart=on-failure
RestartSec=10
StartLimitIntervalSec=120
StartLimitBurst=3

LimitNOFILE=4096

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/atheme/var /opt/atheme/etc
PrivateTmp=true

[Install]
WantedBy=multi-user.target

ZNC Bouncer Unit

ZNC is straightforward because it runs in the foreground by default:

# /etc/systemd/system/znc.service
[Unit]
Description=ZNC IRC Bouncer
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=znc
Group=znc
ExecStart=/usr/bin/znc --foreground --datadir /var/lib/znc

Restart=on-failure
RestartSec=5

LimitNOFILE=8192

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/znc
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target

Type=simple works because of --foreground. systemd tracks the main process directly, no PID file needed. This is the cleanest approach.

Enable and start all three:

sudo systemctl daemon-reload
sudo systemctl enable inspircd anope znc
sudo systemctl start inspircd
# Wait a few seconds for IRCd to be ready
sudo systemctl start anope
sudo systemctl start znc

Security Sandboxing

systemd offers filesystem and capability sandboxing that costs nothing to enable and significantly reduces the attack surface if your IRCd is compromised.

Here's a hardened version with maximum sandboxing:

# Additional sandboxing directives (add to [Service] section)

# Filesystem
ProtectSystem=strict          # Mount / as read-only
ProtectHome=true              # /home inaccessible
PrivateTmp=true               # Isolated /tmp
PrivateDevices=true           # No access to physical devices
ReadWritePaths=/opt/inspircd/data /opt/inspircd/logs

# Kernel
ProtectKernelTunables=true    # No sysctl writes
ProtectKernelModules=true     # No module loading
ProtectKernelLogs=true        # No kernel log access
ProtectControlGroups=true     # No cgroup writes
ProtectClock=true             # No clock changes

# Capabilities
NoNewPrivileges=true          # No privilege escalation
CapabilityBoundingSet=CAP_NET_BIND_SERVICE  # Only bind low ports
AmbientCapabilities=CAP_NET_BIND_SERVICE    # If binding ports < 1024

# Restrict system calls
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources

# Network
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Misc
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
MemoryDenyWriteExecute=true

The CapabilityBoundingSet=CAP_NET_BIND_SERVICE line is needed only if your IRCd listens on ports below 1024 (like 6667). If you use port 6697 only, you can drop all capabilities: CapabilityBoundingSet= (empty).

MemoryDenyWriteExecute=true blocks JIT compilation and self-modifying code. IRCds don't need this. If you load modules that use JIT (unlikely but possible), you'd need to remove this directive.

Test sandboxing before going live:

# Check for issues
sudo systemd-analyze security inspircd.service
# Output shows a security score and lists potential improvements

# Start and check for permission errors
sudo systemctl start inspircd
journalctl -u inspircd -n 50 --no-pager
# Look for EPERM or "Permission denied" errors
Warning: ProtectSystem=strict mounts the entire filesystem read-only except paths you explicitly allow with ReadWritePaths. If your IRCd writes to a directory you forgot to whitelist — such as a module cache or temporary file path — it will fail silently or crash. Test thoroughly after adding each sandboxing directive.

Logging with journald

When an IRC daemon runs under systemd with Type=simple (foreground), its stdout and stderr are captured by journald automatically. No log file configuration needed.

# View live logs
journalctl -u inspircd -f

# View logs from the last hour
journalctl -u inspircd --since "1 hour ago"

# View logs from a specific boot
journalctl -u inspircd -b -1  # Previous boot

# View only errors
journalctl -u inspircd -p err

# Export to a file for analysis
journalctl -u inspircd --since "2024-05-01" --until "2024-05-02" > ircd_may1.log

# JSON output for parsing
journalctl -u inspircd -o json --since "1 hour ago" | python3 -m json.tool

For IRCds running in forking mode, stdout goes to journald but the daemon's internal log files still go to disk. You get both — journald for startup/crash messages and the IRCd's own log files for detailed operational logging.

journald storage is configured in /etc/systemd/journald.conf:

# /etc/systemd/journald.conf
[Journal]
Storage=persistent     # Store on disk (survives reboot)
Compress=yes
SystemMaxUse=500M      # Cap total journal size
MaxRetentionSec=90d    # Keep 90 days
ForwardToSyslog=no     # Don't duplicate to syslog

For IRCds that produce high log volume (debug mode, or busy networks with detailed logging), cap the journal size to prevent disk exhaustion. The SystemMaxUse directive handles this.

Orchestrating the Full Stack

A typical IRC server runs multiple services. Here's the dependency chain:

# Boot order:
# 1. Network comes up
# 2. IRCd starts
# 3. IRC services (Anope/Atheme) connect to IRCd
# 4. ZNC connects to IRCd
# 5. Bots connect (via ZNC or directly)

# You can create a target to manage everything:
# /etc/systemd/system/irc.target
[Unit]
Description=Full IRC Stack
Requires=inspircd.service anope.service znc.service
After=inspircd.service anope.service znc.service

[Install]
WantedBy=multi-user.target

With this target, systemctl start irc.target starts everything in dependency order. systemctl stop irc.target stops everything. systemctl status irc.target shows the status of all components.

For automated restarts after IRCd updates:

#!/bin/bash
# irc-update.sh — Restart the IRC stack in order
set -e

echo "Stopping services..."
systemctl stop anope
systemctl stop znc
systemctl stop inspircd

echo "Starting IRCd..."
systemctl start inspircd
sleep 3  # Give IRCd time to bind ports

echo "Starting services..."
systemctl start anope
sleep 2  # Give services time to connect

echo "Starting ZNC..."
systemctl start znc

echo "Stack restarted."
systemctl status inspircd anope znc --no-pager

This script is deliberate about ordering and includes sleep intervals. In theory, the After= directives handle this. In practice, there's a window between the IRCd process starting and the listening socket being ready. The sleeps account for that gap.

FAQ

My IRCd uses Type=forking but systemd reports it as failed. What's wrong?

The most common cause is a PID file mismatch. systemd expects the PID file at the path specified by PIDFile=. If the IRCd writes it somewhere else, or doesn't write it at all, systemd thinks the process failed to start. Check the IRCd's configuration for the PID file path and match it exactly in the unit file. Alternatively, use Type=simple with a foreground flag to avoid PID files entirely.

How do I run multiple IRCd instances on the same server?

Use systemd template units. Name the file inspircd@.service (note the @) and use %i for the instance name. Then systemctl start inspircd@network1 and systemctl start inspircd@network2 run separate instances with different configs. Each instance needs its own config file, PID file, data directory, and listening port.

Should I use systemd timers instead of cron for IRC maintenance tasks?

For tasks tied to your IRC stack (like database backups or certificate renewal), systemd timers are better because they integrate with journald logging and can depend on your IRC services being running. For generic system tasks, cron is fine. Either works — consistency within your setup matters more than the choice itself.