File Descriptor, Unix Domain Socket and Local Service Architecture with Nginx, Node.js, Go
· 21 min read Đọc bài này bằng tiếng ViệtWhen deploying backend services on Linux, we usually talk about HTTP, TCP ports, reverse proxies, or containers. Under those abstractions, however, sits a simpler and more fundamental primitive: the file descriptor (FD).
Understanding FDs, sockets, and Unix Domain Sockets (UDS) leads to cleaner service architecture: Nginx handles public traffic at the edge, Node.js or Go applications run behind it, and local inter-process communication happens through Unix sockets with explicit filesystem permissions rather than unnecessary TCP ports.
File descriptors: handles for I/O
In Unix/Linux, a file descriptor is a small integer representing an I/O resource opened by a process.
A process may have descriptors such as:
FD 0 -> stdin
FD 1 -> stdout
FD 2 -> stderr
FD 3 -> access log file
FD 4 -> TCP listener on :443
FD 5 -> Unix socket at /run/api/api.sock
FD 6 -> client TCP connection
FD 7 -> upstream connection to an application
An FD does not only represent a regular disk file. Linux exposes many kernel-managed resources through file-descriptor-like interfaces:
- Regular files
- TCP and UDP sockets
- Unix domain sockets
- Pipes and FIFOs
- Device files
- Event primitives such as epoll, eventfd, and timerfd
- Logs, temporary files, and cache files
So “everything is a file” is an oversimplification. A more accurate interpretation is:
Many Unix resources can be accessed and managed through a common file-descriptor interface.
That is why the same families of operations—read(), write(), close(), fcntl(), poll(), and epoll()—work across many different resource types.
A socket is an FD, not a regular file
A native socket() call returns a file descriptor:
int fd = socket(AF_INET, SOCK_STREAM, 0);
You can read from and write to that FD, but a socket behaves differently from a regular file.
| Property | Regular file | Stream socket |
|---|---|---|
| Persistent content | Yes | No |
| Read/write offset | Yes | No |
| Seekable | Usually | No |
| Remote endpoint | No | Yes |
| Backpressure | Usually not network-driven | Yes |
| EOF means | End of file | The peer closed its side |
| Data lifecycle | Stored on disk | Exists while transported |
A TCP connection is a bidirectional byte stream. It does not preserve application message boundaries.
For example, if a sender does this:
write("hello")
write("world")
The receiver might read:
helloworld
Or:
hel
loworld
Or another split entirely.
Therefore, protocols running over TCP or Unix stream sockets need framing. HTTP handles this through headers and body framing; custom protocols may use delimiters, length prefixes, fixed-size headers, or formats such as length-prefixed Protobuf messages.
Socket families and types
Sockets are usually described along two axes: the address family and the transport semantics.
Address families
| Family | Purpose |
|---|---|
AF_INET | IPv4 networking |
AF_INET6 | IPv6 networking |
AF_UNIX / AF_LOCAL | IPC between processes on the same host |
AF_VSOCK | Host-to-VM or guest-to-guest communication |
AF_NETLINK | User space communication with the Linux kernel |
AF_PACKET | Low-level packet and Ethernet frame access |
AF_CAN | CAN bus communication for automotive or embedded systems |
For backend systems, the most common families are AF_INET, AF_INET6, and AF_UNIX.
Socket types
| Type | Characteristics | Common examples |
|---|---|---|
SOCK_STREAM | Reliable, ordered byte stream; no message boundaries | TCP, Unix stream sockets |
SOCK_DGRAM | Message-oriented; preserves datagram boundaries | UDP, Unix datagram sockets |
SOCK_SEQPACKET | Reliable, ordered, message-preserving transport | Some Unix socket use cases |
SOCK_RAW | Raw packet access | Network tools and packet inspection |
Most reverse-proxied web workloads use SOCK_STREAM, either through TCP or Unix stream sockets.
What is a Unix Domain Socket?
A Unix Domain Socket is a socket for inter-process communication between processes on the same host.
Instead of binding a service to an IP address and port:
127.0.0.1:3000
the service can bind to a filesystem path:
/run/my-api/api.sock
The connection topology becomes:
Nginx
|
| connect("/run/my-api/api.sock")
v
Node.js or Go application
The path /run/my-api/api.sock is not a regular file containing requests or responses. It is a filesystem entry that identifies a socket endpoint managed by the kernel.
When viewed with ls -l, a socket usually begins with s:
srw-rw---- 1 my-api nginx 0 Jul 18 15:00 api.sock
This indicates:
s: a socket filesystem entrymy-api: the application ownernginx: the group allowed to access itrw-rw----: permissions restricted to the owner and group
Why use UDS for services on one host?
If Nginx and an application run on the same Linux host, inside the same relevant namespace, and the backend does not need to be reachable over the network, UDS is usually a strong default.
No unnecessary TCP listener
If only Nginx should reach an application, exposing it on:
0.0.0.0:3000
is unnecessary and increases the chance of accidental exposure.
Binding to loopback is better:
127.0.0.1:3000
but it is still a TCP listener. A UDS more clearly expresses the architecture:
Public traffic -> Nginx :80 / :443
Local IPC -> /run/my-api/api.sock
Filesystem permissions become access control
With local TCP, access control often relies on network namespaces, firewall rules, or application-level authentication.
With a Unix socket, filesystem ownership and permissions become part of the access-control boundary:
/run/my-api/api.sock
You can control which processes can connect using Linux users, groups, directory permissions, and socket mode bits.
This is useful for:
- Nginx-to-application reverse proxying
- PHP-FPM
- Node.js real-time services
- Go control-plane daemons
- Local metrics or admin APIs
- Host agents
- Privileged local services
- Docker-style control sockets
Better architectural intent
UDS avoids IP addressing, port allocation, routing, and parts of the network stack that are unnecessary for simple local IPC.
If two processes communicate only because they happen to run on the same machine, UDS describes that relationship more accurately than TCP.
Performance is not the main reason
UDS can avoid some loopback TCP overhead and may be faster in microbenchmarks. That is usually not the main reason to choose it.
For most web services, latency is dominated by:
- Database queries
- Remote API calls
- Serialization and deserialization
- TLS at the edge
- Disk I/O
- Queueing
- CPU saturation
- Garbage collection
- Lock contention
Choose UDS primarily for locality, security boundaries, and operational clarity. Treat performance as a possible secondary benefit.
Nginx is built around FDs
Nginx is a practical example of event-driven I/O around file descriptors.
A worker process may manage many FDs at the same time:
FD for :443 listener
FD for client A
FD for upstream A
FD for client B
FD for upstream B
FD for a static asset file
FD for access log
FD for error log
FD for temporary proxy buffer file
...
For a proxied HTTP request, Nginx normally has at least two active connections:
Client TCP/TLS socket <-> Nginx <-> Upstream socket
When the upstream is a local Node.js or Go service over UDS:
Internet client
|
| TCP + TLS
v
Nginx :443
|
| Unix Domain Socket
v
/run/api/api.sock
|
v
Go or Node.js application
Nginx reads bytes from the client socket FD, parses HTTP, applies TLS, routing, rate limiting, and buffering, then writes the request to the upstream socket FD. The response follows the reverse path.
At the kernel level, Nginx is coordinating data flow across a large set of file descriptors.
Nginx proxying to UDS
A typical Nginx configuration for a Go API might look like this:
upstream api_backend {
server unix:/run/my-api/api.sock;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name api.example.com;
location /api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://api_backend;
}
}
The application protocol remains HTTP. Only the transport between Nginx and the upstream changes:
Before: Nginx -> 127.0.0.1:8080
After: Nginx -> unix:/run/my-api/api.sock
This means REST APIs, HTTP streaming, gRPC-over-HTTP/2 where supported by the proxy path, and WebSocket-related application behavior remain application-level concerns. The main change is how Nginx reaches the local backend.
Node.js with Unix sockets
Node.js supports TCP and Unix stream sockets through the same general abstractions in node:net. The HTTP server can listen on a filesystem path instead of a port.
import fs from 'node:fs';
import http from 'node:http';
const socketPath = '/run/my-api/api.sock';
try {
fs.unlinkSync(socketPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ service: 'node-api' }));
});
server.listen(socketPath, () => {
fs.chmodSync(socketPath, 0o660);
console.log(`Listening on ${socketPath}`);
});
function shutdown() {
server.close(() => {
try {
fs.unlinkSync(socketPath);
} catch (err) {
if (err.code !== 'ENOENT') console.error(err);
}
process.exit(0);
});
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
A key point: net.Socket, HTTP request bodies, and responses are streams. Do not assume a single data event is a complete application message.
For a custom protocol built directly on net.Socket, you must implement framing. For HTTP, Node’s HTTP parser handles that framing.
Backpressure in Node.js
Node stream APIs expose backpressure through the return value of write():
const accepted = writable.write(chunk);
if (!accepted) {
await once(writable, 'drain');
}
If an application continues writing after the writable side is saturated, data can accumulate in memory. This matters for:
- Large streamed responses
- WebSocket fan-out
- Custom Node.js proxies
- Media relays
- AI token streaming
- Slow or malicious clients
Nginx can absorb some pressure for buffered HTTP responses, but it does not remove the need for correct backpressure handling inside the application.
Go with Unix sockets
Go supports Unix sockets through the standard net package.
package main
import (
"log"
"net"
"net/http"
"os"
)
const socketPath = "/run/my-api/api.sock"
func main() {
_ = os.Remove(socketPath)
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatal(err)
}
defer listener.Close()
if err := os.Chmod(socketPath, 0o660); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_, _ = w.Write([]byte(`{"service":"go-api"}`))
})
server := &http.Server{Handler: mux}
log.Printf("listening on unix://%s", socketPath)
if err := server.Serve(listener); err != nil &&
err != http.ErrServerClosed {
log.Fatal(err)
}
}
net.Listen("unix", path) creates a Unix stream listener. The Go http.Server does not care whether that listener is backed by TCP or a Unix socket, as long as it satisfies the net.Listener interface.
That separation is one of Go’s useful design properties:
HTTP server
|
net.Listener
|
TCP listener or Unix listener
The application protocol is not tightly coupled to the underlying transport.
Deadlines in Go
Reads and writes can block. Production services should use timeouts and deadlines to prevent clients from consuming resources indefinitely.
server := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
Nginx needs timeouts, but the application should also enforce its own resource limits. Nginx protects the edge; the application protects itself and its dependencies.
File descriptors are a capacity unit
A common operational mistake is to monitor only CPU, memory, and requests per second. In connection-heavy systems, FD usage is also a first-class capacity metric.
Typical FD consumers include:
- Client connections
- Upstream keep-alive connections
- TCP listeners
- Unix socket listeners
- WebSocket connections
- Database connections
- Redis, Kafka, NATS, or other broker clients
- Static assets
- Log files
- Temporary files
- Parent-child process pipes
- Epoll, eventfd, and inotify descriptors
For a reverse proxy, one active proxied request may require at least:
1 client connection FD
+ 1 upstream connection FD
= 2 FDs
With 20,000 long-lived WebSocket clients, each mapped to an upstream connection:
20,000 client FDs
+ 20,000 upstream FDs
+ listeners, logs, buffers, and runtime overhead
You can hit the open-file limit before CPU or RAM is exhausted.
Typical failures include:
EMFILE: too many open files
accept() failed
socket() failed
open() failed
Aligning FD limits
FD limits should be configured consistently across the stack.
systemd LimitNOFILE
|
v
Nginx worker_rlimit_nofile
|
v
Nginx worker_connections
|
v
Node.js / Go process limits
|
v
Database pools, HTTP client pools, WebSocket limits
For example:
worker_processes auto;
worker_rlimit_nofile 65536;
events {
worker_connections 16384;
}
A worker_connections value of 16,384 does not necessarily mean 16,384 end users when Nginx proxies requests. Upstream connections also consume descriptors, so the real client capacity is lower.
For a systemd service:
[Service]
LimitNOFILE=65536
Do not increase FD limits blindly. Higher limits should be accompanied by:
- Rate limiting
- Connection limits
- Queue limits
- Memory budgets
- Timeouts
- Backpressure
- Load shedding
- Monitoring and alerting
FD limits are not merely a nuisance; they are also a protective boundary.
Nginx buffering and slow clients
Nginx can buffer responses from upstream services. This helps decouple the speed of the backend from the read speed of an Internet client.
Fast Go/Node upstream
|
v
Nginx buffer
|
v
Slow Internet client
For small and medium responses, Nginx can read the response quickly from the backend, release or reuse the upstream connection, and then send data to a slow client at the client’s pace.
If proxy buffering is disabled globally, a slow client can keep the upstream connection occupied for much longer. That increases:
- Active FD count
- Memory pressure
- Goroutine or callback lifetime
- Upstream connection pool occupancy
- Tail latency
Disable buffering only for endpoints that genuinely require end-to-end streaming, such as:
- Server-Sent Events
- Some AI token-streaming APIs
- Long polling
- Certain media relay paths
- WebSocket paths, which have their own upgrade behavior
Example for SSE:
location /events {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
For normal APIs, keeping proxy buffering enabled is usually safer.
WebSockets: long-lived connections mean long-lived FDs
WebSockets begin as HTTP and then upgrade into persistent bidirectional connections.
Browser
|
| HTTP Upgrade
v
Nginx
|
| proxied Upgrade
v
Node.js real-time service
Nginx must forward the upgrade-related headers:
location /ws {
proxy_pass http://realtime_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
Each WebSocket client often remains connected for a long time. Capacity planning must therefore focus on concurrent connections, FD count, memory per connection, and reconnection behavior—not just requests per second.
Node.js is commonly used for connection-heavy real-time systems because of its event-loop and stream APIs. Go is also well suited through goroutines and its network poller. In both cases, the hard constraints remain:
- FD budget
- Kernel socket buffers
- Memory per connection
- Idle timeout policy
- Ping/pong behavior
- Fan-out design
- Downstream backpressure
- Reconnect storms during deployment or failure
UDS lifecycle: permissions and stale sockets
Unlike a TCP port, a UDS has a filesystem path. That creates operational responsibilities.
Place runtime sockets under:
/run/my-api/api.sock
Avoid paths such as:
/tmp/api.sock
./api.sock
/var/www/project/api.sock
/run is intended for volatile runtime state. It is usually cleared on boot, which reduces the risk of stale sockets surviving indefinitely.
A useful systemd unit pattern:
[Unit]
Description=My Go API
After=network.target
[Service]
Type=simple
User=my-api
Group=my-api
SupplementaryGroups=nginx
RuntimeDirectory=my-api
RuntimeDirectoryMode=0750
UMask=0007
ExecStart=/usr/local/bin/my-api
Restart=always
RestartSec=2
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
The intended permissions may look like:
/run/my-api/ owner my-api:my-api, mode 0750
/run/my-api/api.sock owner my-api:nginx, mode 0660
The practical goals are simple:
- The application can create and manage its own socket.
- Nginx can connect to it.
- Unrelated users cannot traverse the directory or connect.
- No application needs to run as root merely to bind the socket.
Graceful shutdown
During deployment, applications should not immediately drop all active connections.
A healthy shutdown sequence is:
1. Receive SIGTERM
2. Stop accepting new connections
3. Let Nginx route new requests elsewhere, if replicas exist
4. Allow in-flight requests to finish within a deadline
5. Close the listener and clean up the socket path
6. Exit
In Go:
ctx, cancel := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer cancel()
go func() {
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(),
20*time.Second,
)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}()
In Node.js, use server.close() to stop accepting new connections, then manage keep-alive and WebSocket connections according to a shutdown deadline.
WebSockets need additional consideration: notify clients, drain instances gradually, or use multiple replicas so that a deployment does not reset every client simultaneously.
When UDS is not the right choice
UDS is not a universal replacement for networking.
Prefer TCP, QUIC, or other network-native transports when:
- Services run on different nodes.
- A service may scale across multiple VMs or physical hosts.
- You need service discovery.
- You need network load balancing.
- Nginx and the backend are in different network namespaces and you do not want to manage a shared socket mount.
- You need cross-platform portability.
- The dependency is managed or external.
- You need standard IP/port-based network observability.
In Kubernetes, UDS can work well between sidecars in the same Pod, or for selected node-level integrations via host mounts. It does not replace Kubernetes Services for cross-Pod or cross-node communication.
Recommended architecture
For a single host running Nginx, a Node.js real-time service, and a Go API, a practical topology is:
Internet
|
TCP/TLS :443
|
v
+-------------+
| Nginx |
| TLS, route, |
| rate limit, |
| static, log |
+-------------+
| |
UDS | | UDS
v v
/run/api/api.sock /run/realtime/app.sock
| |
v v
Go API Node.js real-time
| |
+-------- TCP -------+
|
PostgreSQL / Redis /
Kafka / remote APIs
This split provides several benefits:
- Nginx centralizes public ingress.
- TLS termination, routing, rate limits, caching, and access logs are not duplicated in every service.
- Backends do not need public ports.
- UDS permissions provide a local access-control boundary.
- Node.js and Go retain ownership of business logic, application-level timeouts, and concurrency controls.
- Databases, caches, brokers, and external APIs continue using TCP because they are genuine network boundaries.
Production checklist
- Expose only the public ports required by Nginx, usually
80and443. - Prefer UDS under
/run/<service>/for local backend services. - Never use world-writable sockets such as mode
0666. - Run each service under a dedicated non-root user.
- Grant Nginx only the group permissions it needs to connect.
- Protect both the socket and its parent directory.
- Use systemd
RuntimeDirectoryfor socket lifecycle management. - Remove stale sockets safely during startup.
- Configure
LimitNOFILEconsistently for Nginx and application services. - Monitor FD usage, active connections, upstream errors, queueing, and latency.
- Define connect, read, write, and idle timeouts in both Nginx and the application.
- Retry only idempotent requests, or requests protected by idempotency keys.
- Respect backpressure in Node.js streams and Go I/O paths.
- Do not disable Nginx proxy buffering globally.
- Implement graceful shutdown and connection draining.
- For WebSockets, validate upgrade headers, timeouts, and concurrent-connection capacity.
Closing thought
File descriptors are the Unix primitive that connects files, pipes, sockets, and event-notification mechanisms. A socket is a kernel-managed communication object accessed through an FD; a Unix Domain Socket is the natural expression of local IPC between processes on the same host.
In a practical web stack, Nginx sits at the public edge and coordinates the FDs for client connections, upstream connections, static files, buffers, and logs. Node.js and Go services can sit behind Nginx on UDS, reducing network exposure and using filesystem permissions as a local security boundary.
A useful rule of thumb is:
Use TCP/TLS through Nginx for public traffic. Use UDS for local service-to-service IPC within the same host and trust boundary. Use TCP, QUIC, or another network-native protocol when crossing real network boundaries.
Comments