Reference

Security

How apps are kept apart without Docker, what the panel can and can't do, and the honest limits.

Who it's for: one owner hosting their own projects on their own server. There are no strangers' apps on the box. The danger it guards against is one of your apps getting compromised, through a bad npm package or a bug in the app, and spreading from there to your other apps, the panel or the whole server.

How, without Docker: Docker's isolation is Linux kernel features: namespaces, cgroups and seccomp. systemd uses the same features directly, so every app gets them without images or a daemon.

Each app is fenced in

Its own Linux user. Every app runs as app-<app>: no password, no shell, no SSH login. Its folder, /srv/apps/<app>, belongs to that user and nobody else can read it. Builds run as that user too, in the same kind of sandbox, so a malicious postinstall script is contained the same way the app is.

A sandboxed systemd unit. Each app's unit (/etc/systemd/system/app-<app>@.service) turns on:

Setting Effect
ProtectSystem=strict, ReadWritePaths= The whole filesystem is read-only except the app's data/ folder and the caches its framework needs, like Next.js's .next/cache.
ProtectHome=yes, PrivateTmp=yes, PrivateDevices=yes No home folders, its own /tmp, no devices.
ProtectProc=invisible It can't see other processes.
NoNewPrivileges=yes, an empty CapabilityBoundingSet=, RestrictSUIDSGID=yes It can never gain privileges, even through a setuid binary.
SystemCallFilter=@system-service ~@privileged @resources Only the system calls a normal service needs.
RestrictNamespaces=yes, ProtectKernel*=yes, ProtectClock=yes and others No new namespaces, no kernel tunables, modules or logs, no clock changes.
MemoryMax=, CPUQuota=, TasksMax= One runaway app can't freeze the server; it restarts at its memory limit.

MemoryDenyWriteExecute stays off on purpose: Node's JIT needs it.

layer score <app> shows systemd's own security rating for the unit, from 0 (safest) to 10. A plain service scores around 9.6; app units score around 1.3.

Its own credentials.

  • PostgreSQL: its own role and database, owned by it and closed to every other role.
  • MySQL: its own user, limited to its own database and to logins from 127.0.0.1.
  • MongoDB: its own user, kept in its own database, with access to that database only. MongoDB requires a login for everything and listens on 127.0.0.1 only.
  • Redis: its own Redis process, with its own password and memory cap.

No two apps share a password. The database viewer works inside these walls: it reads as the app's own user, with the app's own login, and every engine is opened read-only.

Secrets it can read but not change. Environment variables are encrypted in the panel's database, with AES-256-GCM. On disk they exist only as /etc/layer/apps/<app>/app.env, owned by root and readable by nobody else. systemd reads that file and hands the variables to the app's process, so the app gets its environment but has no file it could rewrite. Values never appear in build logs.

The network

  • Apps listen on 127.0.0.1 only. Caddy is the only thing listening publicly, on 80 and 443.
  • The firewall denies everything else incoming except SSH, and fail2ban bans addresses that keep failing SSH logins.
  • Caddy's admin API and the panel listen on unix sockets, not on local ports a compromised app could reach.

The panel and the agent

The panel is the most valuable target, so it holds no privileges of its own.

  • Two processes:
    • The web UI, layer-panel, runs as the unprivileged user layer.
    • The agent, layer-agent, runs as root, but takes only a fixed list of actions over a unix socket that only the panel's user can open: create an app's user, write its unit, start it, route a domain, run a build as the app's user, and so on. There's no "run this command" action, and every parameter is checked.
  • Signing in:
    • A one-time setup token for the first visit.
    • A password plus a code from an authenticator app on every sign-in, with recovery codes for a lost phone.
    • Five failed attempts lock out that address and that account for 15 minutes.
  • Who can reach it: Settings → Access can limit the panel to some addresses or to Tailscale. Blocked requests stop at Caddy and never reach the panel. If you lock yourself out, layer access reset on the server undoes it.
  • A record of every change: the agent writes every change it makes to /var/log/layer/audit.jsonl, readable by root only. The Activity page shows it.

The server

The installer turns on unattended security upgrades, fail2ban for SSH, a firewall, and swap on small servers. If an SSH key is present and you agree, it also turns off SSH password logins.

Backups

Backups are encrypted on the server before they leave it, with AES-256-GCM and a new key for each file, derived from your recovery key. Your storage provider only ever sees ciphertext. See Backups.

Honest limits

  • One kernel. All apps share the server's kernel. A kernel exploit could escape any sandbox, and that's equally true of Docker. Only separate virtual machines avoid it, which is out of scope for one owner's own apps.
  • Apps can reach each other's ports on 127.0.0.1. Databases are covered by per-app passwords, but an app's own HTTP port is reachable from the other apps on the server.
  • The panel can decrypt your secrets. It has to, to write them for your apps. Someone who takes over the panel gets your environment variables too, which is why it runs unprivileged, requires two-factor sign-in, and can be locked to your addresses.
  • The agent is root. It's kept small and only takes listed actions, but a bug in it would matter. It's sandboxed by systemd as far as its work allows.