File Descriptor and Unix Domain Socket in Web Service Architecture

· 9 min read Đọc bài này bằng tiếng Việt

When designing web services on Linux, discussions often begin with HTTP, TLS, reverse proxies, ports, and containers. Beneath those layers is a more fundamental primitive: the file descriptor.

File descriptors explain why files, TCP connections, Unix sockets, pipes, and Linux event mechanisms can all be handled through a broadly consistent I/O model. Unix Domain Sockets, or UDS, are a direct application of that model: instead of making services on the same machine communicate through TCP ports, they can communicate through a socket path in the filesystem.

This is especially useful in an architecture where Nginx sits at the public edge while Node.js or Go services run as private backends.

What is a file descriptor?

A file descriptor, usually shortened to FD, is a small integer assigned by the kernel to represent an open I/O resource owned by a process.

A web-service process may hold descriptors like these:

0  -> stdin
1  -> stdout
2  -> stderr
3  -> access log
4  -> TCP listener on :443
5  -> client connection
6  -> upstream connection
7  -> Unix socket listener

An FD does not only represent a disk file. In Linux, it can represent:

  • Regular files
  • TCP or UDP sockets
  • Unix Domain Sockets
  • Pipes between processes
  • Log files and temporary files
  • Event facilities such as epoll, eventfd, and timerfd

So “everything is a file” is best understood in a practical sense:

Linux exposes many I/O resources through a common file-descriptor model.

A process can read, write, close, configure, or wait for readiness on many resource types through primitives such as read, write, close, poll, and epoll.

A socket is a type of FD

When an application creates a socket, the kernel returns an FD. The application uses that descriptor to send and receive data.

With TCP or Unix stream sockets, the socket is a bidirectional byte stream. It does not inherently understand the boundaries of an HTTP request, JSON object, or application message.

For example, a sender may write:

hello
world

The receiver may observe helloworld in one read, or receive the data in several smaller parts. This is why HTTP, gRPC, and custom protocols need framing.

That leads to an important rule:

Sockets transport bytes; protocols define messages.

HTTP defines request and response boundaries. WebSocket defines frames. A custom protocol must define its own message length, delimiter, or framing format.

What is a Unix Domain Socket?

A Unix Domain Socket is a socket used for communication between processes on the same host.

Instead of listening on a TCP address:

127.0.0.1:8080

a service can listen on a filesystem path:

/run/my-api/api.sock

The traffic path becomes:

Nginx -> /run/my-api/api.sock -> application

That path appears in the filesystem, but it is not a regular file containing request or response data. It is the name of a socket endpoint managed by the kernel.

UDS is a good fit when:

  • Both processes run on the same host.
  • The backend should not be directly reachable over the network.
  • There is a clear local trust boundary.
  • Unix users, groups, and filesystem permissions should control access.

Why UDS fits local web services

For a backend that only serves traffic behind Nginx, exposing a TCP listener on 0.0.0.0:3000 is usually unnecessary. Even 127.0.0.1:3000 remains a TCP endpoint, while UDS makes the local-only intent explicit.

A cleaner architecture is:

Internet
   |
HTTPS :443
   |
Nginx
   |
Unix Domain Socket
   |
Node.js or Go service

The main benefit of UDS is not just performance. Its greater value is architectural and operational:

  • Backends do not need their own network ports.
  • Filesystem permissions become an access-control layer.
  • The topology clearly separates public ingress from internal IPC.
  • Local-only services do not require port allocation.
  • There is less risk of accidentally exposing a backend through firewall or network configuration mistakes.

UDS does not replace TCP when services run on different hosts, VMs, Kubernetes nodes, or other real network boundaries. In those cases, TCP, QUIC, or another network-native transport remains the correct choice.

Nginx and file descriptors

Nginx is an event-driven reverse proxy that handles many connections by monitoring the readiness of many FDs.

While proxying a request, Nginx commonly manages at least two socket descriptors:

Client connection <-> Nginx <-> Upstream connection

When a local backend is reached through UDS:

Browser
   |
TCP/TLS
   |
Nginx
   |
Unix socket
   |
Go API or Node.js service

Nginx reads the request from the client socket, performs TLS termination, routing, rate limiting, and buffering, then forwards it through an upstream socket. The response travels in the opposite direction.

Nginx also holds descriptors for static files, access logs, error logs, cache files, and proxy temporary files. This makes FD limits a real capacity concern, especially with high keep-alive concurrency or long-lived WebSocket connections.

Proxying from Nginx to UDS

A minimal Nginx configuration can look like this:

upstream api {
    server unix:/run/my-api/api.sock;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_pass http://api;
    }
}

The backend still serves HTTP normally. Only the transport from Nginx to the application changes:

Nginx -> local TCP -> backend

becomes:

Nginx -> UDS -> backend

This is an infrastructure decision, not a business-protocol change.

Node.js: stream abstractions over sockets

Node.js has an event-driven, stream-oriented model that maps naturally to socket I/O. A Node.js HTTP server can listen directly on a Unix socket path.

import http from 'node:http';

const socketPath = '/run/my-api/api.sock';

const server = http.createServer((req, res) => {
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify({ ok: true }));
});

server.listen(socketPath);

In production, the service should manage the socket lifecycle: ensure the directory exists, safely remove stale socket files at startup, set appropriate permissions, and perform graceful shutdown on SIGTERM.

The most important operational concern in Node.js is backpressure. A socket has finite buffers; if a client reads slowly, an application cannot write indefinitely without growing memory usage.

When a Node stream’s write() returns false, the application should wait for drain before sending more data. This matters most for streaming responses, WebSocket fan-out, media relays, and custom proxies.

Go: HTTP independent of transport

Go naturally separates application protocols from transport. An http.Server accepts a net.Listener, and that listener can be backed by TCP or a Unix socket.

listener, err := net.Listen("unix", "/run/my-api/api.sock")
if err != nil {
    log.Fatal(err)
}

server := &http.Server{
    Handler: handler,
}

log.Fatal(server.Serve(listener))

The HTTP handler does not need to know whether a request arrived through TCP or UDS. A service can change transport without changing its business logic.

The Go runtime manages network polling and allows goroutines to block at the API level while efficiently handling many connections. However, applications still need timeouts, deadlines, connection limits, and body-size limits. Goroutines do not remove FD, memory, or kernel socket-buffer constraints.

FD is a capacity unit

In connection-heavy systems, CPU and memory are not enough. FD usage is also a first-class capacity metric.

An active proxied request may use:

1 FD for the client connection
1 FD for the upstream connection

WebSocket and SSE connections retain those descriptors much longer than typical HTTP requests.

Database pools, Redis clients, log files, static assets, and temporary files can also consume FDs. When the process runs out of descriptors, failures often look like:

too many open files
EMFILE
accept() failed

FD limits should therefore align across the stack:

systemd LimitNOFILE
        |
Nginx worker FD limit
        |
Nginx worker_connections
        |
Node.js / Go process limit
        |
database and outbound connection pools

Increasing LimitNOFILE alone is not enough. It should be paired with timeouts, rate limits, maximum connection limits, backpressure, and observability.

Buffering, timeouts, and slow clients

Nginx can buffer backend responses before delivering them to clients. This allows a backend to complete work quickly without keeping its upstream connection occupied solely because an Internet client reads slowly.

For ordinary API responses, proxy buffering should usually remain enabled. Disable it only for endpoints that truly require end-to-end streaming, such as SSE or selected token-streaming endpoints.

Nginx, Node.js, and Go should all define timeouts for different phases:

  • Upstream connection time.
  • Request header and body read time.
  • Upstream response time.
  • Idle time for keep-alive or long-lived connections.
  • Graceful shutdown deadline.

A robust system is not only one that serves fast clients efficiently. It must also protect itself against slow clients, stuck upstreams, and traffic spikes.

Operating UDS safely

Runtime sockets should live under /run, for example:

/run/my-api/api.sock

Avoid placing them in a shared /tmp directory or in a source directory.

A practical systemd pattern is:

[Service]
User=my-api
Group=my-api
SupplementaryGroups=nginx

RuntimeDirectory=my-api
RuntimeDirectoryMode=0750
UMask=0007

LimitNOFILE=65536

The intended permission model is:

/run/my-api/          my-api:my-api  0750
/run/my-api/api.sock  my-api:nginx   0660

The application can create the socket, Nginx can connect to it, and unrelated processes have no access.

Closing principle

File descriptors are the shared I/O foundation of Unix/Linux. Files, sockets, pipes, and event mechanisms can all be managed through FDs, even though they have different semantics.

Unix Domain Sockets provide a natural way to connect services on the same host. In a common web stack, Nginx receives public traffic over TCP/TLS, while Node.js and Go backends receive local traffic through UDS.

A practical rule is:

Use Nginx and TCP/TLS at the public edge. Use UDS for local IPC within the same host and trust boundary. Use TCP, QUIC, or another network-native transport when crossing VMs, container namespaces, nodes, or real networks.

Comments

  1. Loading comments…