Technology
Why We Skipped Docker for Our PaaS
Container overhead is real. On a 1 GB VPS, native Linux processes beat Docker for density without sacrificing isolation.
Gumisofts
When we started building Gisila Panel, the first question people asked was predictable:
"Why not just use Docker?"
It's a fair question. Docker is the default answer for "how do I run apps on a server?" in 2026. Coolify wraps Docker. CapRover wraps Docker. Dokku wraps Docker. Even Railway and Render ultimately run containers under the hood.
We chose a different path: every app on Gisila Panel is a native Linux process managed by systemd, isolated with Linux users, AppArmor, cgroups v2, and seccomp.
This post explains why.
The RAM tax nobody talks about
When you run an app in Docker, you're not just running your app. You're also running:
- A container runtime shim (
containerd-shimor equivalent) - An isolated network namespace
- An isolated mount namespace
- Image filesystem layers (even when shared, there's bookkeeping overhead)
- The container's own init process
Each container adds roughly 20–100 MB of resident memory overhead before your application's runtime is even loaded.
For a Go binary that uses 8 MB at idle, that overhead is 3–12× the app itself. For a Rust service at 15 MB, you're paying 2–7× just to exist.
Now multiply that across ten apps on a 1 GB VPS:
| Approach | Overhead per app | 10 apps | Apps that fit on 1 GB |
|---|---|---|---|
| Docker container | ~20–100 MB | 200 MB–1 GB wasted | ~8–15 |
| systemd unit | ~0 MB | ~0 MB wasted | ~30–100+ |
On a 1 GB VPS, the difference between 8 containerized apps and 30 sandboxed native processes is not marginal. It's the difference between "this VPS is full" and "I have room for another dozen services."
A bare systemd unit adds approximately zero overhead. Your Go binary is your Go binary. Nothing else is resident.
What "native" actually means
When Gisila Panel deploys your app, here's what happens on the host:
- A dedicated Linux user is created (
app_) - Your code is cloned or extracted to
/srv/apps/app_xxx/ - The runtime builds your app (
go build,cargo build,dart compile exe, etc.) - A systemd unit file is generated with hardening directives
- An AppArmor profile is generated and loaded
- An Nginx vhost is written pointing to
127.0.0.1: systemctl start gisila-app_xxx: your binary runs
There is no image pull. No layer extraction. No container start. Your compiled binary is exec'd directly by systemd.
The filesystem layout follows a Capistrano-style release structure:
/srv/apps/app_xxx/
├── current/ → symlink to active release
├── releases/ → timestamped deployment artifacts
├── shared/ → persistent data (env, uploads)
├── tmp/ → scratch space
└── logs/ → app-specific logsRollbacks swap the current/ symlink and restart the unit. No rebuild required if the artifact is cached.
"But what about isolation?"
This is the second most common question, and it's the right one. Containers exist partly because running arbitrary code on a shared host is dangerous. If we skip Docker, how do we keep tenants apart?
We use the same kernel primitives Docker uses, just without the container wrapper.
Layer 1: Identity isolation
Each app gets its own Linux user:
- System account (
--system), no login shell (/usr/sbin/nologin) - No home directory
- Cannot escalate through PAM modules that filter
uid >= 1000
App A cannot read App B's files because the filesystem permissions say so: the same guarantee Docker provides via user namespaces, but enforced by the kernel directly.
Layer 2: Filesystem isolation
Every systemd unit declares:
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/apps//shared /srv/apps//tmp /srv/apps//logs
ReadOnlyPaths=/srv/apps//current /srv/apps//releases
PrivateTmp=true An app can only write to its own directories. Everything else on the filesystem is read-only or invisible.
Layer 3: AppArmor mandatory access control
The gisila-agent generates a per-app AppArmor profile on every deployment. The profile default-denies access outside the app's work directory and common runtime support files (/usr/lib, /tmp, etc.).
Profiles are loaded with apparmor_parser -r and enforced by the kernel, the same mechanism LXD and Docker use internally.
Layer 4: Kernel hardening via systemd
Every unit sets:
NoNewPrivileges=true: no setuid escalationRestrictNamespaces=true: no new mount/PID/network namespacesMemoryDenyWriteExecute=true: W^X enforcementLockPersonality=true: no personality changesRestrictRealtime=true: no realtime scheduling abuseProtectKernelTunables=true: no/proc/syswritesPrivateDevices=true: no direct hardware accessSystemCallArchitectures=native: 64-bit syscalls only
Layer 5: Resource limits via cgroups v2
MemoryMax=: hard memory cap; OOM-kill is local to the cgroupM CPUQuota=: CPU throttling% TasksMax=: process count limitLimitNOFILE=: file descriptor limit
One runaway tenant cannot starve the host. OOM kills only that app's processes.
Layer 6: Network isolation
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6- Apps listen only on
127.0.0.1:, never on the public interface - Nginx is the sole public ingress; TLS termination happens at the edge
The isolation comparison
| Boundary | Docker | Gisila Panel (systemd) |
|---|---|---|
| Process | container namespace | dedicated Linux user + systemd unit |
| Filesystem | mount namespace + overlay FS | ProtectSystem=strict + AppArmor |
| Memory | cgroup v2 | cgroup v2 (MemoryMax) |
| CPU | cgroup v2 | cgroup v2 (CPUQuota) |
| Network | bridge/overlay network | localhost only + Nginx proxy |
| Syscalls | seccomp profile | AppArmor + systemd seccomp |
| Privileges | dropped capabilities | NoNewPrivileges + no setuid |
The mechanisms are the same. The wrapper is different.
When Docker is the right choice
We're not anti-Docker. Docker is excellent for:
- Reproducible dev environments: "works on my machine" solved
- Polyglot dependency hell: when your app needs exotic system libraries
- OCI image distribution: shipping the same artifact to multiple environments
- Orchestration at scale: Kubernetes, Swarm, Nomad
Optional Docker isolation is on our roadmap (v0.5) as an alternative deployment engine for tenants that explicitly want it. The API and agent interface stay the same; only the backend changes.
But for the MVP, and for our target user, Docker is the wrong default.
Our target user has:
- A $5–20/month VPS
- 10–50 small compiled backend services
- Go, Rust, Dart, or Python apps with minimal system dependencies
- A desire for Heroku-style DX without the RAM tax
For that user, native Linux execution is strictly better on every axis that matters: density, startup time, memory footprint, and operational simplicity.
The operational simplicity argument
There's a less quantifiable benefit: fewer moving parts.
A Docker-based panel on a VPS needs:
- Docker Engine (or containerd + nerdctl)
- A container registry or local image store
- Docker networking (bridge, overlay, or host mode)
- Volume management for persistent data
- Image garbage collection
- Container health monitoring
A Gisila Panel node needs:
- systemd (already there)
- Nginx (already there)
- AppArmor (already there)
- PostgreSQL + Redis (for the control plane)
When something breaks at 2 AM, journalctl -fu gisila-app_myapi tells you exactly what happened. There's no "is it the container or the app?" ambiguity. The process is the app.
The density math, concretely
Let's walk through a realistic scenario.
Setup: Hetzner CX22: 2 vCPU, 4 GB RAM, ~€4/month.
Apps: 25 small backend services (Go APIs, Rust workers, Dart web servers). Average idle RAM: 12 MB each. Average peak RAM: 80 MB each.
With Docker (Coolify-style)
| Resource | Usage |
|---|---|
| Container overhead (25 × 40 MB avg) | 1,000 MB |
| App runtime (25 × 12 MB idle) | 300 MB |
| Control plane (panel + DB + Redis) | 400 MB |
| OS + buffer | 300 MB |
| Total idle | ~2,000 MB |
| Headroom for peaks | ~2 GB (tight) |
You'd feel memory pressure with normal traffic spikes. You might fit 25 apps, but you'd be watching free -h regularly.
With Gisila Panel (native systemd)
| Resource | Usage |
|---|---|
| Container overhead | 0 MB |
| App runtime (25 × 12 MB idle) | 300 MB |
| Control plane (panel + DB + Redis) | 400 MB |
| OS + buffer | 300 MB |
| Total idle | ~1,000 MB |
| Headroom for peaks | ~3 GB (comfortable) |
Same 25 apps. Half the idle RAM. Room to grow to 50+ before you'd need a bigger box.
Conclusion
We didn't skip Docker because containers are bad. We skipped Docker because for our use case (high-density compiled backend hosting on cheap VPSs), the container wrapper costs more than it delivers.
systemd + AppArmor + cgroups v2 gives you the same isolation guarantees with zero per-app RAM overhead. Nginx gives you the same edge routing. Let's Encrypt gives you the same TLS.
What you get back is density, simplicity, and a hosting bill that stays at $5/month even as your app count grows.
That's the bet Gisila Panel is making. So far, it's working.
Previous: ← Introducing Gisila Panel
Next: How a Deployment Works →