Part 2 — How I Run My Entire Digital Life on a Raspberry Pi: Docker and Portainer

Part 2 — How I Run My Entire Digital Life on a Raspberry Pi: Docker and Portainer

Table of Contents

Ready to take your Raspberry Pi to the next level? In this post, I’ll guide you through installing and configuring Docker, the essential platform for running containers. We’ll also set up Portainer, a user-friendly web interface that makes managing your Docker containers a breeze. Whether you’re new to containers or looking to simplify your workflow, this step-by-step guide will get you started quickly and easily.

Image generated by chatGPT’s image generation feature Image generated by chatGPT’s image generation feature

As a recap, in the previous part, we have prepared the hardware, added cooling, SSD, and configured our PI to use encrypted storage for better security.

Install Docker

Let’s begin with the essentials: installing Docker and its prerequisites as root. We’ll start by updating the system, then download the required certificates, and set the appropriate permissions.

# apt update
# apt upgrade
# apt install ca-certificates curl gnupg
# install -m 0755 -d /etc/apt/keyrings
# curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# chmod a+r /etc/apt/keyrings/docker.asc

Create an architecture-specific entry in /etc/apt/sources.list.

# echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# apt update

Now, install the docker engine along with the docker-compose plugin.

# apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Enable the docker subsystem and set it to start on boot.

# systemctl enable docker
# systemctl start docker

Test via docker run hello-world. You should see something similar:

# docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
c9c5fd25a1bd: Pull complete 
Digest: sha256:dd01f97f252193ae3210da231b1dca0cffab4aadb3566692d6730bf93f123a48
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.
...s

Docker Compose’s plugin path isn’t in root’s PATH by default, so let’s add it. Running the command below will help avoid future issues, but the main point is to ensure the correct path for the docker-compose plugin is included.

# echo "export PATH=$PATH:/usr/local/sbin:/usr/sbin:/sbin:/usr/libexec/docker/cli-plugins" >> /root/.bashrc

Now, load the file to activate the new PATH env variable.

# . /root/.bashrc

Test docker-compose:

# docker-compose version
Docker Compose version v2.35.1

Great, everything is working as expected! Now, let’s move on to setting up Portainer. With Portainer, you’ll be able to manage your Docker containers through a user-friendly web interface, so you can handle your future Docker projects without needing to rely on the terminal.

One More Thing Before We Configure Anything: Ask Docker What It Can’t Do

The engine is installed, hello-world ran, docker-compose reports a version. Before we start wiring up networks and storage, ask Docker to introduce itself properly:

# docker info

Scroll to the bottom. On a fresh Raspberry Pi OS install, there’s a good chance you’ll find this waiting for you:

WARNING: No memory limit support
WARNING: No swap limit support

Most people skim past it. Don’t. Those two lines mean every memory limit you will ever write in a docker-compose.yml on this machine is decoration. Set mem_limit: 1g on the hungriest container in your stack, Docker accepts it without complaint, and the kernel ignores it completely.

Raspberry Pi OS ships with cgroup memory accounting off — a small saving that made sense when a Pi had 1GB of RAM and wasn’t running fifteen containers.

One clarification, because this stops otherwise-sensible readers from enabling it: the flag turns on accounting, not limits. Containers without an explicit mem_limit stay exactly as unlimited as they are now. Nothing you already run gets constrained. You’re handing the kernel a measuring tape, not a straitjacket — the Docker resource-constraints docs spell out what each flag actually does.

Two words, appended to a single line in /boot/firmware/cmdline.txt:

cgroup_enable=memory cgroup_memory=1

That file is one line. Append to it, don’t add a newline — a stray line break here produces a Pi that doesn’t boot, and debugging that with no console attached is a bad evening.

Reboot and verify

$ docker info 2>&1 | grep -i warning

Empty output. Good. Progress.

Why bother when nothing you run today is capped? Because of everything we are about to stack on this Pi. A Java-based identity server, a database, a smart-home platform, a monitoring stack — sooner or later one of them will spike unpredictably. Without accounting, that spike means the kernel picks an OOM victim by score, and it does not care that the process it chose was your password manager’s database. With accounting on and a limit set where it’s warranted, the greedy container dies alone and restarts.

Pair this with cAdvisor from Part 15 and the Prometheus/Grafana stack from Part 10 and you go from “I think it’s fine” to actual per-container memory graphs. Which is the difference between running a home server and merely owning one. It is up to your actual setup on how you define these limits, so in those parts, I am not touching this setting.

Right — the engine is genuinely ready now. On to the networks and storage.

Measuring tape, not straitjacket — the containers keep running, we just finally know how big they are. — image generated via Nano Banana Measuring tape, not straitjacket — the containers keep running, we just finally know how big they are. — image generated via Nano Banana

Setup Docker Network and Storage

We’re taking full control of our containers by explicitly managing their storage and network configurations. This approach ensures that nothing is left to chance — there will be no random assignments. Instead, we’ll define fixed storage locations and network settings for every container, giving us complete oversight and consistency across our Docker environment.

Fix the network

We’ll create a dedicated Docker network to manage and assign fixed IP addresses for our containers. By setting up a custom network, we can specify the subnet and assign static IPs, ensuring each container always gets the same address. This makes it much easier to set up port forwarding and access your containers from outside, as you’ll always know which IP to use for each service.

$ sudo docker network create --subnet=172.30.1.0/24 pi_docker_network

This command creates a Docker network with your specified IP range and names it pi_docker_network. Once created, you can view all available Docker networks with:

$ sudo docker network list
NETWORK ID     NAME                DRIVER    SCOPE
c8719e177a41   bridge              bridge    local
c4e8de213ae4   host                host      local
35b2e5b24218   none                null      local
1ed1929996c8   pi_docker_network   bridge    local
$ sudo docker network inspect pi_docker_network
[truncated]
"IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.30.1.0/24"
                }
            ]
        },
[truncated]

Fix the storage

First, we create (for user pi) a /docker directory, where all containers’ storage will be located. In fact, we create this directory on our SSD, because the number of services we are going to run and the amount of data they read/write.

$ sudo mkdir /mnt/storage/docker
$ sudo chown -R pi:pi /mnt/storage/docker

Fix docker subsystem storage

Let’s change the docker subsystem’s storage to be on the SSD as well. First, stop the docker subsystem.

$ sudo systemctl stop docker

Create a directory on the SSD.

$ sudo mkdir /mnt/storage/docker/docker-data

Now, let’s copy all docker-related files and directories to this newly created location.

$ sudo rsync -aP /var/lib/docker/ /mnt/storage/docker/docker-data/

Now, let the docker daemon know that the storage has changed. In order to do so, we need to create a daemon.json file.

$ sudo nano /etc/docker/daemon.json

And add the following to the file:

{
  "data-root": "/mnt/storage/docker/docker-data"
}

Finally, restart the docker subsystem.

$ sudo systemctl start docker

Portainer storage

Create a directory for Portainer inside.

$ cd /mnt/storage/docker
$ mkdir /mnt/storage/portainer

Docker-compose.yaml

Let’s create the compose file for portainer, and start it.

services:
  portainer:
    container_name: portainer
    restart: always
    image: cr.portainer.io/portainer/portainer-ce:latest
    hostname: portainer

    volumes:
      - "/etc/localtime:/etc/localtime:ro"
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "/mnt/storage/docker/portainer/portainer_data:/data"
    ports:
      - "8000:8000"
      - "9000:9000"
    networks:
      pi_docker_network:
        ipv4_address: 172.30.1.250

networks:
  pi_docker_network:
    external: true

Here are some important points to keep in mind:

  • External Network Usage: In the docker-compose.yml, we specify that the network is external, referencing the custom network (pi_docker_network) we created earlier. This ensures our containers use the predefined network instead of Docker creating a random one.
  • Static IP Assignment: Under the networks: section for the Portainer service, we assign it a static IP address (172.30.1.250) within the network’s defined range. This guarantees consistent network access and simplifies future configurations like port forwarding.
  • Port Exposure: We expose the necessary ports to make Portainer accessible from your local network. While this is essential for initial setup and LAN access, you can tighten access later as needed.
  • Persistent Storage: The volume mapping /mnt/storage/docker/portainer/portainer_data/ to /data inside the container ensures all Portainer data is stored persistently on your Pi, surviving container restarts or upgrades.
  • Docker Socket Mapping: By mounting the Docker socket (/var/run/docker.sock), Portainer gains the ability to manage your Docker environment directly.
  • Other Configuration Details: The compose file also includes standard boilerplate: restart policy for reliability, the Portainer image name, settting the local timezone for accurate log timestamps.

This setup gives you a robust, manageable environment, with all key settings explicitly defined for clarity and future scalability.

Time to get this going. Issue the following command as root:

# docker-compose up
[+] Running 11/11
 ✔ portainer Pulled                                                                               27.8s 
   ✔ c02951246260 Pull complete                                                                    1.0s 
   ✔ b7c5ecc05946 Pull complete                                                                    1.1s 
   ✔ 354e5ce52559 Pull complete                                                                    1.3s 
   ✔ 8d59d73e40c6 Pull complete                                                                    2.2s 
   ✔ e811f1a0940a Pull complete                                                                    2.5s 
   ✔ d274720efeae Pull complete                                                                    2.6s 
   ✔ 7bc3f850e674 Pull complete                                                                    3.3s 
   ✔ 9bc69d5084ab Pull complete                                                                    3.5s 
   ✔ 859a3aa0388b Pull complete                                                                    3.6s 
   ✔ 4f4fb700ef54 Pull complete                                                                    3.8s 
[+] Running 1/1
 ✔ Container portainer  Created                                                                    8.0s 
Attaching to portainer
portainer  | 2025/05/20 08:24AM INF github.com/portainer/portaiortainer  | 2025/05/20 08:24AM INF github.com/portainer/porta

As you can see, the image is first pulled and then the container starts running. Normally, you’d use the -d option to run the container in the background, but running it in the foreground lets you monitor the image download and startup status in real time. Once you’ve confirmed everything is working, you can stop the process by pressing Ctrl+C. Then, simply restart the container in detached mode with the following command:

# docker-compose down
# docker-compose up -d

Using docker-compose down is always a good practice to gracefully stop your containers and clean up any temporary resources or networks they created. This ensures your environment stays tidy and avoids leftover states from previous runs.

Now that your container is up and running, you can test its accessibility with a simple telnet command — success.

# telnet localhost 9000
Trying ::1...
Connected to localhost.
Escape character is '^]'.

Let’s navigate our browser to the pi’s IP and port 9000.

Portainer initial startup screen Portainer initial startup screen

Create your user, login, and explore portainer a bit. You might disable the tickbox at the bottom if you are super cautious about your privacy.

Setup the environment

Click on Add Environments Click on Add Environments

After clicking on Add Environments, select Docker Standalone only.

Select Docker Standalone Select Docker Standalone

On the next page, Portainer provides some helpful tips on getting started and managing your environment. However, since you already know the setup steps, you can simply click Close at the bottom of the page. After doing so, you’ll be taken directly to the main Portainer dashboard, where you can start managing your Docker containers with the web interface.

By clicking on your local environment, everything is set up and ready to go.

Dashboard Dashboard

You’ll notice on the Portainer dashboard that you have:

  • 1 stack: This is your Portainer stack, which manages the Portainer service itself.
  • 2 images: These are the hello-world image (from your initial Docker test) and the portainer image.
  • 2 containers: One for hello-world (which may have exited after running) and one for Portainer (which should be running).
  • 4 networks: This includes the default Docker networks plus your custom network that you created earlier.

This overview confirms that your Docker environment is set up correctly and everything is organized as planned!

When you click on Stacks in Portainer, you’ll notice that the Portainer stack is greyed out and cannot be managed through the web interface. This is expected behavior because the stack was created outside of Portainer — typically via the command line or a docker-compose command. Portainer only offers full management capabilities for stacks that were deployed using its own interface; any stack created externally is marked as “limited” or “external,” restricting what you can do with it from within Portainer.

This means you won’t be able to edit, update, or redeploy the Portainer stack from the UI, but that’s perfectly fine for your setup right now. Any other stacks you create through Portainer itself will be fully manageable from the dashboard

our portainer stack is greyed out as it was created outside of portainer our portainer stack is greyed out as it was created outside of portainer

Now, take some time to explore portainer a bit, using the menu on the left. For instance, go to the networks tab and see what the network we created looks like.

In the next part, we’ll focus on securing your local network’s DNS traffic. We’ll set up encrypted DNS queries and distribute them across multiple public resolvers to boost both your privacy and resilience. This approach ensures that your browsing habits remain private and that no single DNS provider can track all your requests. Get ready to take your network privacy to the next level!