Monday, 18 May 2026

Docker Security for Homelab Beginners: Stop Exposing Random Containers

Docker is one of the best and worst things that can happen to a homelab.

Best, because it makes self-hosting ridiculously easy.

Worst, because it also makes it ridiculously easy to expose random containers, run things as privileged, mount dangerous volumes, forget updates, and pretend that “it is inside a container” means “it is safe”.

It does not.

Containers are useful. Containers are convenient. Containers are not magic security boxes.

This post is a practical Docker security guide for homelab beginners. Not enterprise Kubernetes theory. Not compliance paperwork. Just the things I would check on a Linux home server running Docker, especially if that server is always on and slowly collecting services like a digital junk drawer.

The goal is simple:

Stop exposing random containers and understand what your Docker host is actually doing.

1. First, list what Docker is actually running

Before changing anything, look at the current situation.

docker ps

Then check all containers, including stopped ones:

docker ps -a

Now list exposed ports in a cleaner way:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"

This command is important because it shows the part many people forget: what is reachable from outside the container.

You may see something like this:

NAMES        IMAGE              PORTS
jellyfin     jellyfin/jellyfin  0.0.0.0:8096->8096/tcp
grafana      grafana/grafana    0.0.0.0:3000->3000/tcp
postgres     postgres:16        5432/tcp
nginx-test   nginx              0.0.0.0:8080->80/tcp

The interesting part is 0.0.0.0.

When a Docker port is published on 0.0.0.0, it normally means it is listening on all host network interfaces. Docker’s own documentation explains that published container ports are accessible on the host’s network addresses, which may be reachable by external clients depending on the network. See Docker’s official documentation on port publishing and mapping.

In plain English: if you publish a port without thinking, it may be reachable from more places than you expected.

2. Check what the host is listening on

Docker is only one part of the server. Check the host too:

sudo ss -tulpn

Or:

sudo lsof -i -P -n

You want to compare two views:

  • what Docker says it published;
  • what the Linux host is actually listening on.

If you recently followed the UFW firewall post, this is the same idea: do not write firewall rules blindly. First understand what is open.

Useful check:

docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo ss -tulpn
sudo ufw status verbose

Those three commands together already tell a useful story.

3. Stop using -p 8080:80 without thinking

This is probably the most common beginner Docker mistake:

docker run -p 8080:80 nginx

Or in Docker Compose:

ports:
  - "8080:80"

This is convenient, but it may bind the service to all network interfaces.

For a test service that only needs to be used locally on the server, bind it to localhost:

ports:
  - "127.0.0.1:8080:80"

Now the service is only available from the server itself.

If you want to expose it only on a specific LAN IP, use that IP:

ports:
  - "192.168.1.20:8080:80"

That is already better than publishing blindly on every interface.

For a normal homelab, this is one of the best habits to learn:

Publish Docker ports only where they need to listen.

4. Use a reverse proxy instead of exposing every container

A messy homelab often ends up like this:

service1  -> 0.0.0.0:3000
service2  -> 0.0.0.0:8080
service3  -> 0.0.0.0:9000
service4  -> 0.0.0.0:9443
service5  -> 0.0.0.0:5000

That works, but it becomes hard to manage.

A cleaner setup is usually:

  • one reverse proxy exposed on ports 80 and 443;
  • internal containers not directly published to the LAN or internet;
  • service access controlled through the proxy;
  • TLS handled in one place;
  • less random port hunting.

Common reverse proxy options include Nginx Proxy Manager, Traefik, Caddy and plain Nginx.

For example, instead of exposing an app like this:

ports:
  - "8080:80"

You can put it on a Docker network and let the reverse proxy reach it internally.

services:
  app:
    image: nginx
    networks:
      - internal

networks:
  internal:

Then the reverse proxy connects to the app through the Docker network. The app itself does not need a public host port.

This is not just cleaner. It reduces the number of exposed services you need to think about.

5. Be very careful with privileged: true

In Docker Compose, this should make you stop and think:

privileged: true

And with docker run:

--privileged

Privileged containers get much more access to the host than normal containers. Sometimes this is used for hardware access, nested container tools, VPN containers, monitoring agents or quick-and-dirty tutorials.

But for most normal homelab services, it should not be needed.

If a random Compose file asks for privileged: true, ask why.

  • Does it really need access to host devices?
  • Is there a safer capability-based option?
  • Is the image maintained?
  • Do other users run it without privileged mode?
  • Is this container exposed to the network?

If you do not know why a container needs privileged mode, do not enable it casually.

6. Do not mount the Docker socket unless you really understand it

This one is important.

If you see this in a Compose file, pay attention:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

The Docker socket allows communication with the Docker daemon. OWASP’s Docker Security Cheat Sheet warns that exposing the Docker socket to a container is dangerous because it can effectively give that container powerful control over the host through Docker. See OWASP Docker Security Cheat Sheet.

In homelabs, this socket is often mounted for tools like:

  • auto-updaters;
  • reverse proxy companions;
  • management dashboards;
  • monitoring containers;
  • backup helpers.

Some of those use cases are legitimate. But it is not harmless.

A container with access to the Docker socket may be able to start new containers, mount host paths, inspect other containers or change the Docker environment.

So before mounting it, ask:

  • Do I really need this?
  • Is there a read-only alternative?
  • Can I use a restricted proxy for the socket?
  • Is this container exposed to the network?
  • Do I trust this image?

My rule for homelabs:

Mounting /var/run/docker.sock is not “just a volume”. Treat it like admin access.

7. Avoid giving containers huge host volumes

Volumes are another easy place to make mistakes.

This is convenient:

volumes:
  - /:/host

It is also scary.

Same with:

volumes:
  - /home:/home
  - /etc:/etc
  - /var:/var

If the container is compromised or misbehaves, broad write access to the host can turn a small problem into a big one.

Prefer specific folders:

volumes:
  - ./config:/config
  - ./data:/data

Use read-only mounts where possible:

volumes:
  - ./config:/config:ro

For example, a container that only needs to read a config file should not have write access to the whole directory.

Simple habit:

Mount the smallest path possible, with the least access needed.

8. Use environment files carefully

Docker Compose often uses environment variables:

environment:
  - PASSWORD=supersecret
  - API_KEY=somethingprivate

This works, but secrets can easily end up in shell history, Compose files, Git repos, backups or screenshots.

A slightly cleaner pattern is using an .env file:

POSTGRES_PASSWORD=change_this_password
ADMIN_EMAIL=admin@example.local

Then in Compose:

services:
  db:
    image: postgres:16
    env_file:
      - .env

But remember: an .env file is not automatically secure. It is just a file.

Do this:

chmod 600 .env

And do not commit it to a public repository.

If you use Git:

echo ".env" >> .gitignore

For a home server, the practical goal is to avoid accidentally leaking credentials while keeping the setup simple enough to maintain.

9. Do not run everything as root inside the container

Many containers run as root by default. Sometimes that is required by the image. Sometimes it is just the default.

You can check the user inside a container:

docker exec -it container-name id

If it says:

uid=0(root) gid=0(root)

then the process is running as root inside the container.

That does not automatically mean your server is doomed, but it is worth understanding.

Some images support running as a specific user:

services:
  app:
    image: someimage
    user: "1000:1000"

This is not always plug-and-play because file permissions may need adjusting. But for services that support it, running as a non-root user is a good hardening step.

Docker also has rootless mode, which runs the Docker daemon and containers as a non-root user. It can reduce risk, although it also has trade-offs and may not fit every homelab setup.

For beginners, I would not start by migrating everything to rootless Docker. I would start by:

  • reducing exposed ports;
  • removing privileged containers;
  • avoiding dangerous mounts;
  • using maintained images;
  • running containers as non-root when the image supports it.

10. Keep images updated, but do not update blindly

Docker makes updates easy:

docker compose pull
docker compose up -d

Then remove old unused images:

docker image prune

But easy updates can still break things.

Before updating important containers, know where the data lives:

docker inspect container-name

Check Compose files:

ls
cat docker-compose.yml

Back up the important directories first:

tar -czf backup-service-name-$(date +%F).tar.gz ./config ./data

Then update:

docker compose pull
docker compose up -d

Check logs:

docker compose logs --tail=100

For a homelab, I do not like completely blind auto-updates for everything. They are convenient until a breaking change happens at the worst possible time.

A reasonable approach:

  • manually update important services;
  • read release notes for critical apps;
  • keep backups before updating;
  • use auto-updates only for low-risk containers or where you accept the risk.

11. Avoid the permanent “latest” problem

This is common in Compose files:

image: someapp:latest

It is convenient, but it can also surprise you. The tag may point to a newer version later, and that update may include breaking changes.

For important services, consider pinning a major version:

image: postgres:16

Or a specific application version:

image: someapp:1.8.4

This does not mean you should never update. It means you choose when to update.

Good habit:

  • use versioned tags for important services;
  • document why a version is pinned;
  • review versions monthly;
  • avoid ancient images with no updates.

Controlled updates are better than surprise updates.

12. Remove containers and images you forgot about

Homelabs collect old tests.

Check stopped containers:

docker ps -a

Remove containers you no longer need:

docker rm container-name

Check images:

docker images

See Docker disk usage:

docker system df

Clean unused images:

docker image prune

Clean unused containers, networks and images:

docker system prune

Be careful with volumes:

docker volume ls

Do not delete volumes blindly. Volumes may contain actual application data.

This command can delete unused volumes:

docker volume prune

Use it only if you understand what will be removed.

13. Keep Docker networks understandable

List Docker networks:

docker network ls

Inspect a network:

docker network inspect network-name

In Compose, avoid throwing every service onto one giant shared network unless they really need to talk to each other.

Example:

services:
  app:
    image: myapp
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
  backend:

In this example, the database is only on the backend network. It does not need to sit next to every other service.

For a small homelab, you do not need a complicated network design. But you should avoid one massive “everything talks to everything” setup if the services do not require it.

14. Put databases behind the app, not on the LAN

A very common bad pattern:

postgres:
  image: postgres:16
  ports:
    - "5432:5432"

Now PostgreSQL is published on the host.

If only your app container needs the database, do not publish the database port:

services:
  app:
    image: myapp
    depends_on:
      - postgres
    networks:
      - backend

  postgres:
    image: postgres:16
    networks:
      - backend
    volumes:
      - ./postgres-data:/var/lib/postgresql/data

networks:
  backend:

The app can reach postgres:5432 through the Docker network, but the database is not exposed on the host.

This is cleaner and safer.

Same idea for:

  • MariaDB / MySQL;
  • Redis;
  • MongoDB;
  • internal APIs;
  • admin-only dashboards.

If it does not need to be reachable from the LAN, do not publish it.

15. Use UFW, but remember Docker can complicate it

UFW is great for home servers, but Docker can make firewall behavior less obvious because Docker manages its own networking rules.

So after adding or changing containers, check again:

docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo ufw status verbose
sudo ss -tulpn

Then test from another machine:

nc -vz 192.168.1.20 8080
nc -vz 192.168.1.20 3000
nc -vz 192.168.1.20 5432

Or scan a small set of ports:

nmap -p 22,80,443,3000,5432,8080,8096,9000,9443 192.168.1.20

The important thing is not assuming.

If your UFW rules say something should be blocked, but another machine can still reach it, investigate Docker port publishing and iptables behavior.

This is why I like binding containers to localhost or to a specific LAN IP instead of publishing everything blindly.

16. Be careful with management dashboards

Dashboards are useful. Dashboards are also dangerous when exposed carelessly.

Examples:

  • Portainer;
  • Grafana;
  • Prometheus;
  • Uptime Kuma;
  • Nginx Proxy Manager;
  • database admin tools;
  • file browser tools.

These should not be casually exposed to the internet.

For admin dashboards, prefer:

  • LAN-only access;
  • VPN access;
  • strong unique passwords;
  • two-factor authentication if available;
  • reverse proxy authentication where appropriate;
  • no default credentials, ever.

Bad idea:

ports:
  - "0.0.0.0:9000:9000"

Better for local-only use:

ports:
  - "127.0.0.1:9000:9000"

Better for LAN-only use:

ports:
  - "192.168.1.20:9000:9000"

Then combine that with UFW rules and actual testing from another device.

17. Add health checks where they help

Health checks are not exactly “security”, but they help you notice broken services.

Example:

services:
  app:
    image: nginx
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost"]
      interval: 30s
      timeout: 10s
      retries: 3

Then check:

docker ps

You may see health status in the output.

A secure homelab is not only about blocking attacks. It is also about noticing when something is unhealthy, broken or behaving strangely.

18. Read logs before restarting randomly

When something breaks, the classic homelab reaction is:

docker restart everything

Resist the urge.

Read logs first:

docker logs container-name --tail=100

For Compose:

docker compose logs --tail=100

Follow logs live:

docker compose logs -f

Look for:

  • authentication failures;
  • permission errors;
  • unexpected external IPs;
  • database connection errors;
  • crash loops;
  • failed updates;
  • disk full messages.

Logs are boring until they save you.

19. Back up Compose files and data directories

Your containers are usually easy to recreate. Your data is not.

For each service, know where these live:

  • docker-compose.yml
  • .env
  • config directory
  • data directory
  • database volume
  • uploaded files

Example backup:

tar -czf docker-backup-$(date +%F).tar.gz docker-compose.yml .env config data

Or with rsync:

rsync -aAX --info=progress2 /srv/docker/ /mnt/backup/docker/

Before major updates, back up first.

Before deleting volumes, back up first.

Before rebuilding an important service, back up first.

Backups are not glamorous, but they are usually the difference between “small problem” and “weekend ruined”.

20. A safer Docker Compose example for a homelab

Here is a simple example showing some better habits.

services:
  app:
    image: nginx:stable
    container_name: homelab-nginx
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    volumes:
      - ./html:/usr/share/nginx/html:ro
    networks:
      - frontend

networks:
  frontend:

This is not perfect for every situation, but notice the ideas:

  • the port is bound to localhost only;
  • the container is not privileged;
  • the filesystem is read-only;
  • extra Linux capabilities are dropped;
  • the mounted content is read-only;
  • the image uses a stable tag instead of blindly using latest.

Not every image will work with all of these settings. Some containers need write access, capabilities or specific permissions. But this gives you a direction.

Start strict. Loosen only what the application actually needs.

21. A practical Docker security checklist

Here is the short version I would run through on a Linux home server.

# Show running containers and exposed ports
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"

# Show all containers, including old stopped ones
docker ps -a

# Show host listening ports
sudo ss -tulpn

# Show Docker disk usage
docker system df

# Show Docker networks
docker network ls

# Show Docker volumes
docker volume ls

# Check firewall
sudo ufw status verbose

Then ask:

  • Are any containers exposed on 0.0.0.0 unnecessarily?
  • Are admin dashboards reachable from the internet?
  • Are databases published to the host?
  • Are any containers using privileged: true?
  • Is /var/run/docker.sock mounted anywhere?
  • Are host folders mounted too broadly?
  • Are important images pinned to sensible versions?
  • Do backups include Compose files and data?
  • Do you know how to restore the service?

If you can answer those questions, your homelab is already ahead of many “temporary” Docker setups.

22. My personal homelab Docker rules

These are the simple rules I like to follow:

  • Do not publish a port unless a real device needs to access it.
  • Bind test services to 127.0.0.1.
  • Keep databases internal to Docker networks when possible.
  • Avoid privileged: true unless there is a real reason.
  • Treat the Docker socket as admin access.
  • Use specific volume mounts, not giant host mounts.
  • Use strong unique passwords for dashboards.
  • Back up Compose files and data directories.
  • Update intentionally, not blindly.
  • Test exposure from another machine.

This is not fancy. That is the point.

A secure homelab should be understandable. If I look at a Compose file six months later, I want to know why each port, volume and permission exists.

Final thoughts

Docker makes it very easy to run services at home. That is why we like it.

But the same convenience can turn a clean Linux server into a pile of exposed dashboards, forgotten test apps, old images, broad volume mounts and containers with far more access than they need.

The fix is not to stop using Docker.

The fix is to use it with a little discipline:

  • check what is running;
  • check what is exposed;
  • bind ports deliberately;
  • avoid privileged containers;
  • protect the Docker socket;
  • keep databases internal;
  • back up the data;
  • review the setup occasionally.

Containers are not magic security boxes.

But with sane defaults, boring rules and a little testing, Docker can be a very good fit for a Linux homelab.

And the next time you paste a random Compose file from the internet, stop for ten seconds and check the ports section.

That small pause may save you a lot of trouble later.

No comments:

Post a Comment