Unix Domain Socket (UDS) can be a fantastic tool that we can use. In this post I explain what it is, speak about advantages and disadvantages as well. Keep in mind, that every aspect I mention, is related for Linux. So it may work differently on MacOS or Windows, but, at least from my view, server operating system is a Linux distribution.
Introduction
Let’s start with basic terminologies, that I use during this post. I am not going details them, rather explain its functionality with some easy and understandable analogy.
What is a socket?
Simplest way to understand, think about a electric plug on the wall: you plug-in your device, then electricity flow through the devices. Just like a socket on the wall, socket in a computer workds similiarly.
Via socket, we are able to send and receive data over various protocol, like
TCP, UDP or Unix Domain Socket. Sockets has a common API that implements system
calls like socket(), bind(), connect() or accept(). Just like everything
in Linux, it is also a file. Which technically mean, each socket has a file
descriptor.
Inter Process Communication
The Inter Process Communication (or IPC) is self-explanatory. It is usual that we can have micro services or servers and clients that are connected with each other. But within the same operating system, we also want applications to communicate.
IPC has multiple solution, in this post, this is only mentioned and explained via Unix Domain Socket.
Advantages of using Unix Domain Socket
Unix Domain Socket (or UDS) is a tool for IPC. If a server and client communicate via UDS, it is happens inside the kernel. Which means that cannot be any middle man action, like we can have if we talk over internet. This gives an extra security to UDS, but is also give a limitation: UDS connection only possible within the same system.
Since UDS communication happening via kernel memory, no middle man, which leads to the conclusion: we do not have to care with TLS. Which means that no TLS handshake required during connection, no encryption is required. This releases some computing power and reduce latency.
UDS communication is done via a file based socket, which means that no IP address
or port number is required. Because this is “just a file”,
we can restrict access to socket using chown or setfacl commands. We can
also use systemd socket unit file, you see example for it later.
What is Unix Domain Socket?
UDS are filed-based sockets and implemented via AF_UNIX or AF_LOCAL.
Process create a file (e.g.: /run/user/1000/server.sock)
and bind on that file. Clients can connect to that file and send/receive data
over it. UDS can be used on bidirectional way.
UDS implements a namespace, but they don’t have persistend data like regular
files. In the following, I show an output for stat command, then analyze the
output.
$ stat ~/test.txt
File: /home/ati/test.txt
Size: 10 Blocks: 8 IO Block: 4096 regular file
Device: 0,43 Inode: 455137 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ ati) Gid: ( 1000/ ati)
Context: unconfined_u:object_r:user_home_t:s0
Access: 2025-05-01 20:25:03.412865542 +0200
Modify: 2025-05-01 20:25:00.472405623 +0200
Change: 2025-05-01 20:25:00.476437061 +0200
Birth: 2025-05-01 20:25:00.472405623 +0200
$ stat /run/user/1000/podman/podman.sock
File: /run/user/1000/podman/podman.sock
Size: 0 Blocks: 0 IO Block: 4096 socket
Device: 0,78 Inode: 3509 Links: 1
Access: (0660/srw-rw----) Uid: ( 1000/ ati) Gid: ( 1000/ ati)
Context: unconfined_u:object_r:user_tmp_t:s0
Access: 2025-05-24 21:26:37.616319228 +0200
Modify: 2025-05-24 21:26:10.917680796 +0200
Change: 2025-05-24 21:26:10.917680796 +0200
Birth: 2025-05-24 21:26:10.916943941 +0200
We can see that UDS is indeed a file, it has inode, links, and so on. But we can see that its size and number of blocks are zero. This is because behind a UDS there is no real data block. UDS files are special file types with no persistent data the actual data transfer happens entirely within kernel space.
The file type also indicates it is a socket and not regular file.
But from a high level view, it similar like a network socket: we can use SOCK_STREAM
(TCP) or SOCK_DGRAM (UDP) over it. We can also use standards on application
layer like HTTP. Unix domain sockets also support SOCK_SEQPACKET, which offers
message boundaries with guaranteed ordering and delivery.
Linux supports abstract sockets (@mysocket) which don’t create actual files on disk.
Prove it’s a real socket communcation and not regular file
For this experiment, I create two Go process. Code of server:
// simple_server.go
package main
import (
"net"
"os"
)
func main() {
os.Remove("/tmp/echo.sock")
l, _ := net.Listen("unix", "/tmp/echo.sock")
for {
conn, _ := l.Accept()
go func(c net.Conn) {
buf := make([]byte, 128)
n, _ := c.Read(buf)
c.Write([]byte("Echo: " + string(buf[:n])))
c.Close()
}(conn)
}
}
And I also create a client:
// uds_client.go
package main
import (
"fmt"
"net"
)
func main() {
conn, _ := net.Dial("unix", "/tmp/echo.sock")
conn.Write([]byte("ping"))
buf := make([]byte, 128)
n, _ := conn.Read(buf)
fmt.Println(string(buf[:n]))
conn.Close()
}
After build, I run both using strace utility. This utility make record about
system calls. We can check if any open call happening. The trace output is
large, I am not going copy the whole here, but I would like to highlight some
interesting lines.
$ grep -E 'AF_UNIX|listen\(|bind\(|open\(' server.trace
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
bind(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, 17) = 0
listen(3, 4096) = 0
getsockname(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0
accept4(3, {sa_family=AF_UNIX}, [112 => 2], SOCK_CLOEXEC|SOCK_NONBLOCK) = 4
getsockname(4, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0
$ grep -E 'AF_UNIX|listen\(|bind\(|open\(' client.trace
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
connect(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, 17) = 0
getsockname(3, {sa_family=AF_UNIX}, [112 => 2]) = 0
getpeername(3, {sa_family=AF_UNIX, sun_path="/tmp/echo.sock"}, [112 => 17]) = 0
We can see, indeed no open syscall happened. If you have already seen any
code for network socket, you could have seen that similar calls are performed
transmission: socket(), connect() and accept().
If you ever want to listen all UDS, you can do ss -x -a | grep LISTEN. This
filters for the listening sockets.
Communication flow over UDS
Communication between server and client is represented on the following diagram.
Example for UDS using
Several applications uses this interface, like Docker. Via Docker socket you can get same information than using Docker CLI. This can be handy in programming, because it is much easier to parse a JSON comparing with a CLI output.
Next sample command list the currently running container’s id.
$ curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json \
| jq '.[].Id'
"2538d78c2034cb3b144b66fc552bcf7cb84436f2b3534cefbbc62fd8e16cea1e"
"2355b6beb03fe8d06b1be9489f65a7f96f2f2583ec8f748e373e521cdc90483c"
"d068b04712964c23d89a0593ea5eb9e2b0d8f64a0c8097ca55230544488d514c"
"f098a6c04382393bebb729ced2183c2b69463ed533d2492ba293c47e035056e8"
"32c52cebe43fc538a99cd57f92a253bc452dbe65a75848c0466b9c873221c71c"
"5698cc832a361f7ec596cca9a366d1d34aa699a3c647cf8993a50a14b7179fe0"
Build HTTP on UDS
Like in case of Docker in the previous example, it is possible to build an HTTP server on UDS. And why would not do it? HTTP is a very stable standard for communication, having very rich tooling. And building a simple REST API using standard Go library, is simpler than you would thought.
package main
import (
"net"
"net/http"
"os"
)
func main() {
os.Remove("/tmp/hello.sock")
l, _ := net.Listen("unix", "/tmp/hello.sock")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello over UDS!"))
})
http.Serve(l, nil)
}
If you reading this code, you can see that we called the unlink call via
os.Remove() function. The kernel don’t automatically override it.
Run the server above, then execute curl --unix-socket /tmp/hello.sock http://localhost/
code as test.
If you want to add middleware, routers, handlers, and so on, it is just up to you. It does not matter that it is UDS, your application can work as a regular network based REST API. Except a few thing. For example, in this case no source IP address, because, due to limitation of UDS, connection can just coming via local system.
Authenticate on UDS
If you want to use authentication, like OIDC or OAuth2, no problem, you can still do that. But with UDS you can also filter that which user or group id is authorized to use it. We can limit who can have access, on two ways:
- Authorization of socket file: Only those can write the socket who has
write access for the file. At the end of the day, UDS is also a file. You can
use
chmodorsetfaclto modify the access on the socket. - SO_PEERCRED: This return with a struct that hold information from the user who issued the request.
For a moment, forget about REST API. I show this function on a much simpler code.
// peerceed_server.go
package main
import (
"fmt"
"net"
"os"
"golang.org/x/sys/unix"
)
func main() {
sockPath := "/tmp/peercred_example.sock"
os.Remove(sockPath)
listener, err := net.Listen("unix", sockPath)
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Listening on", sockPath)
for {
// Infinite wait for new connections
conn, err := listener.Accept()
if err != nil {
continue
}
unixConn, ok := conn.(*net.UnixConn)
if !ok {
fmt.Println("Not a UnixConn")
conn.Close()
continue
}
file, err := unixConn.File()
if err != nil {
conn.Close()
continue
}
defer file.Close()
// Use x/sys/unix to fetch SO_PEERCRED
cred, err := unix.GetsockoptUcred(int(file.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
if err != nil {
fmt.Println("GetsockoptUcred error:", err)
conn.Close()
continue
}
fmt.Printf("Client connected: PID=%d UID=%d GID=%d\n", cred.Pid, cred.Uid, cred.Gid)
conn.Write([]byte("Welcome, verified client!\n"))
conn.Close()
}
}
Following client is used during testing:
// peercerd_client.go
package main
import (
"fmt"
"net"
"os"
)
func main() {
conn, err := net.Dial("unix", "/tmp/peercred_example.sock")
if err != nil {
fmt.Println("Dial error:", err)
os.Exit(1)
}
defer conn.Close()
buf := make([]byte, 256)
n, _ := conn.Read(buf)
fmt.Print(string(buf[:n]))
}
Server process produces output like this:
$ ./peercred_server
Listening on /tmp/peercred_example.sock
Client connected: PID=119446 UID=1000 GID=1000
Client connected: PID=119455 UID=1000 GID=1000
Client connected: PID=119464 UID=1000 GID=1000
When and why is PEERCRED useful?
If you say that you are able to protect the socket file is a fine argument. But there can be other, non-functional requirements, that we have to fulfill like: logging. If we work for a government, bank or any other institute, then logging is always is a question.
And this brought up the next question: how can I authenticate using SO_PEERCRED with a REST API?
Authenticate with SO_PEERCRED in a REST API
From application view, the most logical decision would be a to write a middleware. But from system view, that is not a good way because, we should hijack the file descriptor and working with that. Might be a good solution, I have found that very ugly.
Instead, I create a new listener on top of UnixListener. This new listener
is fulfill all required functions for the interface, so we can simply use it
in http.Serve method.
// A Listener is a generic network listener for stream-oriented protocols.
//
// Multiple goroutines may invoke methods on a Listener simultaneously.
type Listener interface {
// Accept waits for and returns the next connection to the listener.
Accept() (Conn, error)
// Close closes the listener.
// Any blocked Accept operations will be unblocked and return errors.
Close() error
// Addr returns the listener's network address.
Addr() Addr
}
The code of the new listener can be seen below. It basically prevent to accept request from any other user than the owner of the process.
// authListener wraps a UnixListener and filters based on UID
type authListener struct {
*net.UnixListener
}
func (l *authListener) Accept() (net.Conn, error) {
conn, err := l.UnixListener.AcceptUnix()
if err != nil {
return nil, err
}
// Extract file descriptor
file, err := conn.File()
if err != nil {
conn.Close()
return nil, err
}
defer file.Close()
// Get peer credentials (SO_PEERCRED)
cred, err := unix.GetsockoptUcred(int(file.Fd()), unix.SOL_SOCKET, unix.SO_PEERCRED)
if err != nil {
conn.Close()
return nil, err
}
// Accept only connections from the same user
allowedUID := uint32(os.Getuid())
if cred.Uid != allowedUID {
log.Printf(
"❌ Rejected connection: UID %d (only UID %d allowed)",
cred.Uid,
allowedUID,
)
conn.Close()
return nil, tempError{fmt.Errorf("unauthorized UID %d", cred.Uid)}
}
log.Printf("✅ Accepted connection from UID=%d PID=%d", cred.Uid, cred.Pid)
return conn, nil
}
// tempError makes rejected connections non-fatal for http.Serve
type tempError struct {
err error
}
func (t tempError) Error() string { return t.err.Error() }
func (t tempError) Timeout() bool { return false }
func (t tempError) Temporary() bool { return true }
The server part is similar than before, only difference that we use this listener.
package main
import (
"fmt"
"log"
"net"
"net/http"
"os"
"golang.org/x/sys/unix"
)
const socketPath = "/tmp/auth.sock"
func main() {
os.Remove(socketPath)
// Start listening on a UDS path
l, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
defer l.Close()
// Set permissions if needed
if err := os.Chmod(socketPath, 0660); err != nil {
log.Fatalf("Failed to set socket permissions: %v", err)
}
fmt.Println("Listening on", socketPath)
// Wrap listener to intercept connections
err = http.Serve(&authListener{l.(*net.UnixListener)}, http.HandlerFunc(handler))
if err != nil {
log.Fatalf("HTTP server error: %v", err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("✅ You are authorized!\n"))
}
More advanced solutions may exists, this is just a simple introduction. A sample output from the server.
$ ./http_auth_cred
Listening on /tmp/auth.sock
2025/05/24 23:17:56 ❌ Rejected connection: UID 0 (only UID 1000 allowed)
2025/05/24 23:17:56 http: Accept error: unauthorized UID 0; retrying in 5ms
2025/05/24 23:17:59 ❌ Rejected connection: UID 0 (only UID 1000 allowed)
2025/05/24 23:17:59 http: Accept error: unauthorized UID 0; retrying in 10ms
2025/05/24 23:18:03 ✅ Accepted connection from UID=1000 PID=121373
Do you need TLS?
If we implement any service, it is a default in 2025 that we must use trusted certificate with out deployment. Why? Because that traffic goes over internet and at any point a interceptor can act and watch out traffic.
But for now, we know that UDS does not goes over neither internet nor any network. It only available from the machine locally and communcation is done via kernel memory.
Concolusion is, that TLS it not needed. This does not mean the communication is encrypted but since it never leaves the local system and access can be controlled via filesystem permissions and peer credentials, TLS is usually unnecessary.
Of course, a good logging and sometimes UID/GID based authentication can be useful.
Final words
UDS is a simple and secured way for any IPC on Linux. It still secure without handling any TLS configuration, no certificates are required. It also has a very nice integration with systemd.
If I make any dev tool to myself or local API, I use UDS without question. I encourage anybody to do the same.
Bonus (Optional): Using socket with systemd
What problem do we solve, if we used systemd? Let’s imagine a situation: you deploy an application where the application owner is not a priviledged user and wants to allow other user’s to having access for the socket. We can solve this problem by using systemd socket. This allows the socket to be created with proper ownership and permissions before your app starts.
In a systemd socket file, we can define details of it like in the following example.
[Unit]
Description=Socket for UDS-authenticated HTTP server
[Socket]
ListenStream=%t/demo.sock
SocketMode=0660
SocketUser=ati
SocketGroup=ati
[Install]
WantedBy=sockets.target
If this unit is started, it creates a new socket (unlink if already exists). In this case, we have to modify our application, becuse application has to just use a pre-defined file descriptor.
Previous example has been modified. You can see that in the main, we don’t bind
or listen, we just use existing file descriptior that we get from systemd via
LISTEN_FDS and LISTEN_PID environment variables. This examples assumnes
that only one socket is passed to the application, thus LISTEN_FDS is ignored
and assumed that its value is 3.
The first file descriptor may be found at file descriptor number 3 (i.e. SD_LISTEN_FDS_START), the remaining descriptors follow at 4, 5, 6, …, if any.
package main
import (
"fmt"
"log"
"net"
"net/http"
"os"
"strconv"
"golang.org/x/sys/unix"
)
func main() {
listenPidStr := os.Getenv("LISTEN_PID")
if listenPidStr == "" {
log.Fatal("LISTEN_FDS or LISTEN_PID not set. Are you using systemd socket activation?")
}
// Confirm PID matches
pid := os.Getpid()
expectedPid, _ := strconv.Atoi(listenPidStr)
if pid != expectedPid {
log.Fatalf("LISTEN_PID %d does not match current PID %d", expectedPid, pid)
}
// LISTEN_FDS tells us how many FDs were passed; first is always FD 3
fd := 3
file := os.NewFile(uintptr(fd), "systemd-socket")
listener, err := net.FileListener(file)
if err != nil {
log.Fatalf("Failed to use systemd socket: %v", err)
}
fmt.Println("Using socket passed by systemd")
// Proceed with your wrapped listener
err = http.Serve(&authListener{listener.(*net.UnixListener)}, http.HandlerFunc(handler))
if err != nil {
log.Fatalf("HTTP server error: %v", err)
}
}
Then, create a unit file for this go process. Name match with the socket unit file, difference that its exteions is service.
[Unit]
Description=Go HTTP Server with UDS + UID Auth
Requires=demo.socket
After=network.target
[Service]
ExecStart=%h/tmp/uds_go/systemd_server
NonBlocking=true
StandardOutput=journal
StandardError=journal
Restart=always
After a systemctl --user daemon-reload command, we can start the socket then
execute a test curl command.
$ systemctl --user start demo.socket
$ systemctl --user status demo.docker
Unit demo.docker.service could not be found.
$ systemctl --user status demo.socket
● demo.socket - Socket for UDS-authenticated HTTP server
Loaded: loaded (/home/ati/.config/systemd/user/demo.socket; disabled; preset: disabled)
Active: active (listening) since Sun 2025-05-25 00:00:36 CEST; 8s ago
Invocation: d36ab5e4641d49bcafbc43a261bca513
Triggers: ● demo.service
Listen: /run/user/1000/demo.sock (Stream)
Tasks: 0 (limit: 18919)
Memory: 0B (peak: 768K)
CPU: 2ms
CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/demo.socket
máj 25 00:00:36 atipc systemd[1830]: Starting demo.socket - Socket for UDS-authenticated HTTP server...
máj 25 00:00:36 atipc systemd[1830]: Listening on demo.socket - Socket for UDS-authenticated HTTP server.
$ systemctl --user status demo.service
○ demo.service - Go HTTP Server with UDS + UID Auth
Loaded: loaded (/home/ati/.config/systemd/user/demo.service; static)
Drop-In: /usr/lib/systemd/user/service.d
└─10-timeout-abort.conf
Active: inactive (dead)
TriggeredBy: ● demo.socket
After a curl command, we can see that systemd starts the service. This is useful for more reasons:
- App only runs when needed
- Permissions and socket ownersips handled by systemd
- Automatically restart on failure
- Multiple socket support
- Works well with systemd
$ curl --unix-socket /run/user/1000/demo.sock http://localhost/
✅ You are authorized!
$ systemctl --user status demo.service
● demo.service - Go HTTP Server with UDS + UID Auth
Loaded: loaded (/home/ati/.config/systemd/user/demo.service; static)
Drop-In: /usr/lib/systemd/user/service.d
└─10-timeout-abort.conf
Active: active (running) since Sun 2025-05-25 00:01:13 CEST; 1s ago
Invocation: 2cf86f6a356844e79cc4207c18ef06b4
TriggeredBy: ● demo.socket
Main PID: 130394 (systemd_server)
Tasks: 6 (limit: 18919)
Memory: 1.3M (peak: 2.5M)
CPU: 5ms
CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/demo.service
└─130394 /home/ati/tmp/uds_go/systemd_server
máj 25 00:01:13 atipc systemd[1830]: Started demo.service - Go HTTP Server with UDS + UID Auth.
máj 25 00:01:13 atipc systemd_server[130394]: Using socket passed by systemd
máj 25 00:01:13 atipc systemd_server[130394]: 2025/05/25 00:01:13 ✅ Accepted connection from UID=1000 PID=130393
