I audited my Docker server and found exactly what you'd expect
I have been running a Docker host with a couple dozen containers for a couple of years. Document management. Reverse proxy. DNS. Monitoring. Identity provider. The usual mix. Like every homelab anyone has ever run, it grew organically — "add a service, get it working, move on," repeated for twenty-four months.
Nobody was reviewing the security posture. Nobody had time. It was "just a homelab."
Then I decided to actually look.
The findings weren't surprising in the abstract. Of course the secrets were in plaintext. Of course the Docker socket was mounted somewhere it shouldn't have been. Of course a third of the services had no authentication. The pattern is universal — you start by being careful and you end by being tired.
But seeing it written down, in priority order, was useful. It's harder to keep telling yourself "I'll fix that someday" when "that" is a numbered list with severity ratings, and the someday is still hypothetical.
This is the audit. The process is generic, the lessons are not.
#The audit was just reading
I didn't use a scanner. I read every compose file, every env file, every docker inspect output, top to bottom, and asked the same questions about each one:
- Where are secrets stored?
- What network mode is the container using?
- Which ports are exposed, and to which interfaces?
- Is the container running as root?
- Does it have unnecessary privileges or capabilities?
- Is authentication enabled?
- Are images pinned to specific versions?
Two hours of reading. No tools. No certifications. The kind of audit anyone running a Docker host can do, and basically nobody does, because nobody is making them.
#The worst finding was the most predictable one
Plain-text secrets in compose files. Everywhere. Not in one stack — across most of them.
The pattern was always the same: the service asked for an API key during setup, I pasted it into the env file, it worked, I moved on. Two years of that is twenty-something env files containing every credential my infrastructure depends on.
What was actually in there:
- AWS access keys for a DNS management service (one active, one commented out but still readable in the file)
- VPN private keys — the kind whose compromise lets an attacker impersonate the tunnel
- VPN account credentials — full-account-management bearer tokens
- Half a dozen application API keys with full admin access to their respective services
- Database passwords, including one application's whose
SECRET_KEYenables session forgery if leaked - Application encryption keys whose compromise enables cookie forgery and, in at least one stack, potential remote code execution
If anyone with read access to my Docker host (a compromised container, a misconfigured backup, a scp from a stolen laptop, or a confused future-me running cat .env) saw those files, the blast radius was everything.
This is the finding that motivated the next month of work. It's also the finding I'd bet money is sitting in your Docker host right now, if you have one. I'm not the special idiot. I'm just the one who looked.
#The Docker socket is the skeleton key
One of the containers had /var/run/docker.sock mounted read-write. A different one had it read-only. A third also had it read-only.
If you don't know what this means: mounting the Docker socket into a container is equivalent to giving that container root-equivalent access to the host. Read-write is obvious — the container can create new containers, including privileged ones, including ones that mount the host filesystem. That is root. The container has root.
Read-only is more subtle. It can't create containers. But it can inspect every other container on the host, which means it can read every environment variable in every container, which — given the previous finding — means it can read every secret on the box. Read-only Docker socket access is root-equivalent for credentials, even if it's not quite root for actions.
I had three containers with this access. One of them genuinely needed it (a container management tool). Two of them were mounting the socket out of habit, because the documentation suggested it for a feature I wasn't using.
The single highest-risk finding wasn't the read-write socket. It was that I had stopped noticing the socket mounts existed. When you're not auditing, the dangerous defaults stop registering.
#The unauthenticated services were the most embarrassing
Several services on the host had no auth at all:
- A web-based monitoring tool — open to anyone on the LAN
- A document-processing tool with security explicitly disabled in its config
- A Redis instance with no password, accessible to every other container on the same bridge network
- A headless browser controllable via API with no token
- A database protected only by a weak, guessable default password
Anyone on my LAN — including a compromised IoT device on the same broadcast domain, or a guest who'd been given the WiFi password — could reach these services. With VLAN segmentation done properly, the IoT and guest exposure goes away. Without it, the LAN is a single shared trust zone, and "anyone on the LAN" is a longer list than I want to think about.
This is the embarrassing finding. Not because the services were exposed — because every one of them was one config flag away from being authenticated. The flag was off because nobody had thought about it during setup. The config never got revisited. The default became the production state.
#Host network mode and root by default
A few containers were running with host network mode — sharing the host's network stack with no isolation. One of them was running as root, with AppArmor disabled, with the D-Bus socket mounted, and with logging turned off. That single container had the most concentrated risk on the entire host. If it ever got compromised, the post-incident forensics wouldn't find anything, because it wasn't logging anything.
Most of the containers were running as root. Either no user directive in the compose file, or the image didn't respect the PUID/PGID environment variables. Only a handful were properly configured non-root.
Combined with the missing security_opt: ['no-new-privileges:true'] flag — which only one container had — a vulnerability in any of these containers could lead to privilege escalation. The flag is a one-line addition. It blocks setuid escalation. There's no reason not to set it.
#And every image was on :latest
Every. Single. One. A few were on :develop, which is worse.
:latest means:
- No reproducible builds.
docker compose upcan pull a different image tomorrow than it pulled yesterday. - Supply-chain risk. A compromised release of an upstream image gets pulled automatically.
- No rollback path. "What version were we on before" is a question with no answer.
Pinning takes ten seconds per image. The cost of not pinning compounds forever.
#The hierarchy of bad
Not every finding is equally urgent. The audit's value is in the prioritization, not the discovery.
The order I worked them in:
- Move secrets out of compose files — every other improvement is downstream of this. As long as twenty env files hold every credential I have, no other security control matters very much.
- Bind admin ports to specific interfaces — stop exposing admin panels to
0.0.0.0. Bind to the host IP or to localhost. Most admin panels have no business being reachable from any other host on the network. - Set passwords on the unauthenticated services — every service gets auth, no exceptions. Every one of these is a config flag.
- Add
no-new-privileges:trueto every stack — one line, defense-in-depth, zero downside. - Delete unused stacks — old compose files I wasn't running anymore but could accidentally start. Forgotten compose files are a footgun. Delete them.
- Review the highest-combined-risk container — the one running on host network with AppArmor disabled and no logging. That whole config needed a rethink, not a tweak.
- Pin image versions for critical infrastructure — at minimum the reverse proxy and the identity provider. Other stacks can pin opportunistically.
The first one is the load-bearing one. Everything else is cleanup. Get the secrets out of the files. The Vault deployment that came two weeks later is the post that closes that finding.
#Why I'd write this down again
A few honest observations after the fact.
Homelabs grow exactly like production systems do. Nobody plans to have secrets in plaintext and unauthenticated services. It happens incrementally. You add one container, paste a key, it works, you move on. After two years, you have twenty-something containers and the security debt is real. This is exactly what happens in corporate environments, with the same mechanism. The only difference is that companies have compliance requirements that force periodic audits. Nobody is forcing you to audit your homelab. That's why so few people do it.
The Docker socket is the skeleton key. Read-write is obvious. Read-only is the underrated version. If a service legitimately needs the socket — and some do — that container should be the most locked-down service on the host. Minimal capabilities, no host network, strong authentication, logging on, and reviewed every time you change anything about it.
"Just a homelab" is not an excuse. A homelab with a VPN, with remote access via WireGuard, with IoT devices on the network, and with a reverse proxy exposed to the internet is not a toy. It's infrastructure. A compromised container on my server could have reached my network, my documents, my media archive, my DNS, and my VPN tunnels. That isn't "a homelab compromise." It's "a network compromise that started in the homelab."
The audit document goes in version control. Findings get tracked like a backlog. Closed when fixed. Each closure has a commit message. "Treating security findings like a backlog — prioritized, tracked, and closed — is how you make steady progress without trying to fix everything at once." The first audit is for the inventory. Every subsequent audit is for the diff.
#The progress so far
Since the audit, the work in flight:
- Identity provider deployed — one place to log in, one audit log, one place to revoke access. The proxy outpost in front of the unauthenticated services is the next layer.
- Secrets manager deployment in progress — a real fix for the number-one finding, with a migration plan that touches every stack opportunistically rather than all at once.
- Stack hygiene —
no-new-privilegesadded to every stack where it's safe to add (a handful of containers legitimately need privilege escalation; those got a closer review instead). Pinned versions on the reverse proxy and identity provider. Deleted the old compose files I wasn't running anymore but could accidentally start.
Nothing about that list is heroic. Each pass surfaces things I didn't see the first time, and the homelab keeps growing, so the audit will keep finding new things. That's also fine. The point isn't to be done. The point is to know.
#How to audit your own
You don't need a security cert. You don't need a tool. You need an afternoon.
For each container on your host, answer:
- Secrets —
grep -i 'key\|secret\|password\|token' compose-file.yml. If anything is in plaintext, that's a finding. - Network — is it on
hostmode? Does it need to be? What ports are exposed and to which interface? - Privileges — running as root? Mounts the Docker socket? Has extra capabilities (
NET_ADMIN,SYS_ADMIN)? - Auth — can you reach the web UI without logging in? Is the default password still set?
- Versions — are you pinned, or are you on
:latest?
Write down what you find. Sort by "how bad is this if it's exploited." Fix the worst stuff first. That's a security audit. It is genuinely that simple.
The afternoon you spend doing this will be one of the highest-leverage afternoons of the year, if it's the kind of homelab you can imagine being mad about losing.