Cyber Security

Container Security for Home Servers — From Docker Rootless to seccomp

Container Security for Home Servers — From Docker Rootless to seccomp

A few months ago, I started to get serious about separating services on a home server using Docker. One container for the web, one for the database, another for small tooling. It feels neat, like an apartment with many rooms, each of which has its own function. But over time I realized: having lots of rooms doesn't automatically make the house safe. If one occupant can open another room or even go out onto the main road, then "separate" is just an illusion.

That's why container security is a topic that must be understood once we are comfortable using Docker. Containers are light and practical, but their isolation is different from virtual machines. This article is my take on the three layers of defense that are easiest to implement: rootless container, capability drop, and seccomp.

Containers Are Not VMs — Isolation Is Thin

One common misconception is that containers are as isolated as VMs. Even though the container is more like process which is wrapped in namespace and cgroups. They share the same kernel as the host. If there is a gap in the kernel or container runtime, escape from the container to the host can occur.

Imagine the container like a boarding room in a shared house. Each has its own lock, but all share the same walls, pipes, and electrical systems. The room provides privacy, but is not a separate home. If someone manages to damage the wall, he can enter the general area.

The best strategy is not to stop using containers, but to add layers of restrictions so that each container only has the minimum capabilities that are truly needed.

First Layer: Don't Run Container as Root

By default, the process in the container runs as root (UID 0). Inside the container it is isolated, but if an escape occurs, the attacker immediately gets root on the host. It's like giving each resident the master key to an apartment.

The simplest solution: create a normal user in the image, then use the USER instruction in the Dockerfile.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup
USER appuser
CMD ["node", "server.js"]

With the Dockerfile above, the application runs as UID 1001. If the attacker manages to exit the container, he does not immediately become root. Small steps, but big impact.

Furthermore, there is the concept of rootless Docker or rootless Podman. Here, the Docker daemon itself does not run as root. The container is run by a normal user, in fact the UID 0 in the container is actually mapped to the user's UID on the host. This reduces surface attack drastically.

# Install rootless Docker (Debian/Ubuntu)
dockerd-rootless-setuptool.sh install

# Jalankan container rootless
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker run --rm -u 1000:1000 -v $HOME/project:/app:ro myapp

Rootless containers do have limitations, for example they cannot bind to ports below 1024 without additional configuration. But for the home server, usually we can use a reverse proxy on the host that forwards to the container's high port.

Second Layer: Capability Drop

In Linux, root has many "superpowers" called capabilities: it can change file ownership, open raw ports, bind sockets, and others. By default, containers get a subset of these capabilities. We can limit it by drop everything first, then add only what is really necessary.

The principle is similar to giving permission to an application on a cellphone: don't give access to the camera, microphone and location if the application is just a calculator.

# Drop semua capability, lalu tambahkan hanya yang dibutuhkan
docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --cap-add=CHOWN \
  --name webapp \
  myapp:latest

For containers that really don't need anything special, just --cap-drop=ALL without any --cap-add. Ordinary applications such as static web servers or worker queues often don't need any capabilities at all.

The command below displays the default capabilities that Docker provides. Try checking before starting to limit:

docker run --rm -it ubuntu:24.04 capsh --print

Third Layer: Filter System Calls with seccomp

seccomp is a Linux kernel feature that allows us to filter what system calls a process can execute. Docker already has a default seccomp profile that blocks around 44 malicious system calls. But many modern applications don't need all the system calls that are still permitted.

Imagine seccomp like an access list in an office: not just who can enter, but also what can be done inside. If the application only needs to read files, listen to sockets, and write logs, then system calls to load kernel modules or change memory mapping do not need to be allowed.

An example of a simple seccomp profile that allows only common syscalls:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
  "syscalls": [
    {
      "names": [
        "accept", "accept4", "bind", "brk", "clone", "close", "connect",
        "epoll_create", "epoll_create1", "epoll_ctl", "epoll_pwait", "epoll_wait",
        "exit", "exit_group", "fcntl", "fstat", "futex", "getcwd", "getpid",
        "getrandom", "getsockname", "getsockopt", "ioctl", "listen", "lseek",
        "mmap", "mprotect", "munmap", "nanosleep", "open", "openat", "poll",
        "read", "readv", "recvfrom", "recvmsg", "rt_sigaction", "rt_sigprocmask",
        "rt_sigreturn", "select", "sendmsg", "sendto", "setitimer", "setsockopt",
        "socket", "socketpair", "stat", "write", "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Save as seccomp-web.json, then run container:

docker run -d \
  --security-opt seccomp=seccomp-web.json \
  --cap-drop=ALL \
  --name restricted-app \
  myapp:latest

Creating your own seccomp profile requires in-depth understanding. Practical way: start from the default Docker, then add blocks gradually while running integration tests. If the application errors, note the required syscalls and allow them carefully.

Network and Volumes: Limit Container Range

Isolation doesn't stop at the process. The network also needs to be set up. By default, containers can communicate with each other via a bridge network. If one container is compromised, the attacker can scan other containers on the same network. On a home server, we can usually limit it to a user-defined network or even not provide network access at all if it's not necessary.

# Container tanpa akses jaringan (misalnya worker lokal)
docker run -d --network none myworker

# Container hanya bisa diakses lewat reverse proxy
docker run -d --network my-private-net --name webapp myapp
docker run -d --network my-private-net --name db postgres

Volume must also be considered. Avoid mounting sensitive host folders to containers unless absolutely necessary. If necessary, use the read-only option (:ro) so that the container cannot change its contents.

docker run -d \
  -v /home/adam/project:/app:ro \
  -v /home/adam/project/data:/app/data:rw \
  myapp

Additional Practices That Are Easy to Forget

Apart from the three layers above, there are several small habits that I often try to implement:

  • Do not expose host ports unless necessary. Do not use -p 3306:3306 for databases if only other containers need access. Just use the internal network.
  • Use minimal images. alpine, distroless, or slim reduces surface attack. The fewer tools in the image, the fewer there are that can be misused.
  • Scan images regularly. Tools such as trivy or grype can detect CVEs in image dependencies.
  • Rotate secret. Do not hardcode passwords, API keys, or tokens in images. Use environment variables or secret management.
  • Update runtime and kernel. Container security is highly dependent on the kernel. Make sure the host kernel is always up-to-date.

Conclusion

Containers make deploying applications on a home server neater, but neat is not the same as safe. The three most fundamental steps that can be implemented immediately are: run the container as non-root, eliminate drop capability, and consider seccomp to limit system calls. Coupled with tight network and volume settings, we have reduced the risk significantly.

Container security isn't about making a server 100% impenetrable — that's practically impossible. But it's about making things difficult for attackers with layers of restrictions, so that if one container is compromised, the impact doesn't immediately spread to the entire system.

On my own home server, I have directed every container that is currently running to use a non-root user and drop capability. The process is slow, but it feels calmer because each "resident" of this digital apartment only has the key to his or her own room.

Do you have container security practices that you routinely implement on your home server? Or is this your first time hearing about rootless Docker and seccomp? Write in the comments column, I'm happy to learn from your experience too.