In this journal, we will take a look on how to minimize idle services running in the background using systemd-socket-proxyd(8) for services that we cannot control their code and using systemd.socket(5) for services that we can changes their code.

Background

Lets take a look running processes on my host machine (click on the image to enlarge):

Host processes with top sorted by VIRT column

Host process

At the top, we have a process with 261.3 gigabytes virtual memory address. This service rarely used, only once a day or less.

Below that, we have HTTP services that I manage for local development using systemd user services. For example, this website has their own local domain kilabit.local that run on specific port.

My goal is to reduce the running processes and activate it only when I opened the port or domain in the browser or when some other services connect to the port directly.

As for measurement, we count number of tasks using ps, memory using free, and boot time using systemd-analyze after booting and login. Here is the initial value,

$ ps -e | wc -l
198

$ free -m
               total        used        free      shared  buff/cache   available
Mem:           15620        3562       10370         142        2132       12058
Swap:           4095           0        4095

$ systemd-analyze
Startup finished in 6.868s (firmware) + 2.737s (loader) + 934ms (kernel) + \
	1.693s (initrd) + 6.198s (userspace) = 18.432s
graphical.target reached after 6.193s in userspace.

$

Lets start with external services first.

Using systemd-socket-proxyd

For external services where I cannot changes their code, I use systemd-socket-proxyd.

systemd-socket-proxyd is a generic socket-activated network socket forwarder proxy daemon for IPv4, IPv6 and UNIX stream sockets. It may be used to bi-directionally forward traffic from a local listening socket to a local or remote destination socket.
— systemd-socket-proxyd
https://man.archlinux.org/man/systemd-socket-proxyd.8

For example, let say I have service X that listen on port 8080. We will let the systemd.socket listen to that port and run the actual service on port 18080. Any other services still connect to port 8080 to communicate to service X through systemd-socket-proxyd.

First, we create the /etc/systemd/system/proxy-X.socket that will listen on port 8080,

[Socket]
ListenStream=127.0.0.1:8080

[Install]
WantedBy=sockets.target

Next, we create the /etc/systemd/system/proxy-X.service that will run systemd-socket-proxyd and channel the traffic to port 18080,

[Unit]
Requires=X.service
Requires=proxy-X.socket
After=X.service
After=proxy-X.socket

[Service]
Type=notify
ExecStartPre=/bin/sleep 3
ExecStart=/usr/lib/systemd/systemd-socket-proxyd --exit-idle-time=5m \
	127.0.0.1:18080
ExecStopPost=systemctl stop X.service

Note that, in this case, we add the ExecStartPre= to wait for X.service run successfully, and the ExecStopPost= to stop the service. Both of these may or may not needed, depends on the service.

In the X.service, we override the service unit file to run on port 18080 instead of default port,

# /etc/systemd/system/X.service.d/override.conf
[Unit]
StopWhenUnneeded=true

[Service]
ExecStart=
ExecStart=/usr/bin/X --Port 18080

With —exit-idle-time=5m and StopWhenUnneeded=true, we tell the systemd to stop the service when no traffic received after 5 minutes.

Now stop the actual service and start the proxy-X.socket service,

$ sudo systemctl disable --now X.service
$ sudo systemctl enable --now proxy-X.socket
$

We test it by opening the service port :8080 in the browser. Here is sample log in the journal,

Feb 01 18:20:11 systemd[1]: Started X Daemon.
Feb 01 18:20:11 systemd[1]: Starting proxy-X.service...
<TRUNCATED>
Feb 01 18:20:14 X[8144]: Now listening on: http://127.0.0.1:8080
Feb 01 18:20:14 systemd[1]: Started proxy-X.service.

After 5 minutes we will the service stopped automatically,

Feb 01 18:25:34 systemd[1]: proxy-X.service: Deactivated successfully.
Feb 01 18:25:34 systemd[1]: Stopping X Daemon...
Feb 01 18:25:34 X[8144]: Application is shutting down...
Feb 01 18:25:34 X[8144]: 02-01 18:25:34 Info X stopped
Feb 01 18:25:34 systemd[1]: X.service: Deactivated successfully.
Feb 01 18:25:34 systemd[1]: Stopped X Daemon.
Feb 01 18:25:34 systemd[1]: X.service: Consumed 4.010s CPU time over 5min 23.145s wall clock time, 163.1M memory peak.

Great. We can repeat the same configurations to other services.

Using systemd.socket

Now, the hard part is to rewrite all of the programs so it can be activated using systemd.socket(5).

All of the program that we want to convert are built with Go. In the previous journal, I have describe how to modify the Go program so it can be activate using systemd.socket(5). If you have a program that need to be converted too, but not written using Go, you can read the following blog first to understand the basic,

We will modify each of the main function in the program. There are at least 11 of them. Fortunately, most of them are using the same library/package.

  • local.awwan.service ⇒ ciigo based. This serve the local version of https://awwan.org .

  • local.golang-id.service ⇒ ciigo based. This serve the local version of https://golang-id.org .

  • local.golang-id-tour.service ⇒ net/http based. This serve the local version of https://tour.golang-id.org .

  • local.home.bahasa-go.service ⇒ ciigo based.

  • local.home.gitdoc.service ⇒ httpdfs ⇒ lib/http based. This serve the Git documentation directory

  • local.home.go.service ⇒ net/http based. This run golang.org/x/website for offline documentation (the same content as https://go.dev ). You can view the patch here (subject to changes in the future).

  • local.home.godoc.service ⇒ net/http based. This run golang.org/x/pkgsite with local modules. You can see the patch here (subject to changes in the future).

  • local.home.gorankusu.service ⇒ lib/http based.

  • local.home.ops.service ⇒ lib/http based

  • local.kilabit.service ⇒ ciigo based.

  • local.kilabit.umum.service ⇒ httpdfs ⇒ lib/http based.

Most of the modification are adding the following identical code,

...
	listeners, err := systemd.Listeners(true)
	if err != nil {
		log.Fatal(err)
	}
	if len(listeners) > 1 {
		log.Fatal(`too many listeners received for activation`)
	}
	var listener net.Listener
	if len(listeners) == 1 {
		listener = listeners[0]
		gotAddr := listener.Addr().String()
		if gotAddr != serveOpts.Address {
			log.Fatalf(`invalid Listener address, got %s, want %s`,
				gotAddr, serveOpts.Address)
		}
	}
	// Pass the listener to HTTP Server.
...

Results

Let’s reboot the machine and see the results.

$ diff -u before after
--- before      2026-02-02 23:52:11.677244743 +0700
+++ after       2026-02-02 23:52:25.315331792 +0700
@@ -1,12 +1,12 @@
 $ ps -e | wc -l
-198
+182

 $ free -m
                total        used        free      shared  buff/cache   available
-Mem:           15620        3562       10370         142        2132       12058
+Mem:           15620        2898       12491         102         601       12722
 Swap:           4095           0        4095

 $ systemd-analyze
-Startup finished in 6.868s (firmware) + 2.737s (loader) + 934ms (kernel) + \
-       1.693s (initrd) + 6.198s (userspace) = 18.432s
-graphical.target reached after 6.193s in userspace.
+Startup finished in 6.804s (firmware) + 1.438s (loader) + 985ms (kernel) + \
+       1.508s (initrd) + 5.985s (userspace) = 16.722s
+graphical.target reached after 5.983s in userspace.

The number of processes started on boot reduced by 16, the total memory used reduced by 664 MB (-18%), and the boot time reduced by 1.71 second (-9%). Well, it is not so bad. The big win is in the reduced memory usage and learning how to use systemd.socket and systemd-socket-proxyd.