Portainer for your Homelab

A step-by-step guide to getting started with Docker, Portainer, and securely accessing the Portainer container via a Tailscale companion container running Tailscale Serve for reverse-proxy.

Portainer for your Homelab
the end result of this article

This post is going to cover quite a bit. I'm going to walkthrough getting Docker onto a fresh Ubuntu install, utilizing Portainer to manage our Docker containers, and using Tailscale to connect to it so that only our private tailnet can manage it.

Files used can be found here.

I'm going to be using a completely fresh install of Ubuntu Desktop 24.04 with installation defaults as the host.

Looks like by default docker isn't there. I'm not a huge fan of snap and the other two are "unofficial". So instead we follow docker documentation. First we add the repository to apt.

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

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

Now we do the install.

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

We try the docker ps command again.

It works, but our user isn't added to the docker group, requiring us to use sudo. So if we want to run docker commands without it, let's add ourself to the docker group and try again.

First sudo usermod -aG docker $USER adds the current user you're logged in with to the group, then newgrp docker to let the permissions apply without having to logout/login. Third time's the charm, we run docker ps again to verify.

If you haven't created a Tailscale account yet, do so. Once you login to the account, we need to do a couple steps. Download and install the agent on the PC you'll be using to visit web urls, otherwise when we set up Portainer to use Tailscale, we won't be able to access it.

Under the DNS tab of the Admin console, down at the bottom, you'll want to enable the MagicDNS and HTTPS Certificate settings if you haven't used Tailscale Serve before.

Go to the Access controls tab and modify your file to make sure the line for tagOwners is uncommented (including closing bracket) and include "tag:container": ["autogroup:admin"], within it. This tells us that anything with the tag container can be added by those users within the automatically created admin group, which you as the owner will be placed into already.

Navigate next to Settings > Keys > Generate auth key.

Fill out the fields similar to below and make sure you add the tag you just created.

Copy the code that is generated somewhere securely as someone with this password will be able to onboard new devices to your tailnet. You'll need it for the next step as well as any other containers you'd like to put behind Tailscale.

To make Tailscale Serve work with Portainer, we'll need the container to reference a configuration json. Make a directory via mkdir -p ~\appdata\ts-portainer\config and then nano ~\appdata\ts-portainer\config\portainer.json and copy/paste the following. CTRL-O to write the file and CTRL-X to exit if this is your first time with nano.

{
  "TCP": {
    "443": {
      "HTTPS": true
    }
  },
  "Web": {
    "${TS_CERT_DOMAIN}:443": {
      "Handlers": {
        "/": {
          "Proxy": "https+insecure://localhost:9443"
        }
      }
    }
  }
}

This tells Tailscale to forward to the Portainer's webui on port 9443 and ${TS_CERT_DOMAIN} is Tailscale magic that pulls from the container itself with the hostname and MagicDNS url.

Let's get into the Portainer bits. Fill out this to get your free license that allows up to 3 nodes aka the hosts we install it on, with all their features. With that license we modify their deployment guide for docker standalone.

If you want to run Portainer off the host without a Tailscale container, you can run docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v ~/appdata/portainer:/data portainer/portainer-ee directly and skip over the next bit.

A little break down of the above. -d tells it to run in the background so it's not taking over your terminal session. -p are the ports that the host listens and passes to the container, in host:container format. --name is the name of the container shown in docker ps results and referenced by other containers. --restart with the always option tells the container to restart – aka if it crashes for some reason, or if the host reboots. The -v is the volume mapping, where we are mapping the hosts docker.sock to the container, and a new folder we made to where the container stores it's data in the user's home directory. The final bit of portainer/portainer-ee is telling docker to pull that image for the container's use.

However, I'm a fan of docker compose so I'm going to go differently from what the portainer steps decree. We need to make a compose.yml and a .env for use in the container creation. This way it's a file you can revisit if you forget how you created and it falls off your terminal history. Create the .env to store the tailscale secret and the appdata path as we'll be referencing it multiple times. Make sure you are in the home directory ( cd ~ if unsure) nano .etc and fill it out similar:

TSKEY_AUTH=tskey-auth-blahblahblah-blahblahblah
APPDATA=~/appdata

Then nano compose.yml and we paste the following and save it.

services:
  ts-portainer:
    image: tailscale/tailscale:latest
    container_name: ts-portainer
    hostname: portainer
    environment:
      - TS_AUTHKEY=${TSKEY_AUTH}
      - TS_EXTRA_ARGS=--advertise-tags=tag:container
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_USERSPACE=false
      - TS_SERVE_CONFIG=/config/portainer.json
    volumes:
      - ${APPDATA}/ts-portainer/state:/var/lib/tailscale
      - ${APPDATA}/ts-portainer/config:/config
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin
      - sys_module
    restart: unless-stopped
  portainer:
    image: portainer/portainer-ee
    container_name: portainer
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ${APPDATA}/portainer:/data
    depends_on:
      - ts-portainer
    network_mode: service:ts-portainer
volumes:
  ts-portainer:
    driver: local

Breaking the compose down. We have two services ts-portainer which we're using for Tailscale and portainer the actual Portainer container.

In ts-portainer image is used to pull the actual container image, container_name is used so we have consistency and not adding a new device to our tailnet everytime a container get's spun up, and hostname is used to pass the hostname for our MagicDNS url.

For the environment we're passing a couple things, TS_AUTHKEY reads the .env file we put in our auth key, so it can be onboarded to the tailnet. TS_EXTRA_ARGS gives it the container tag since that's needed for our onboarding. TS_STATE_DIR is a mandatory flag tailscale needs to be persistent for checks, we point it to where to look in the container and then use the ${APPDATA}/ts-portainer/state:/var/lib/tailscale to tell the container where to map on the host. TS_USERSPACE is another mandatory argument since we're running this without a user logged in. TS_SERVE_CONFIG tells the container where to find it's the configuration for Tailscale Serve, which is the other mapping we have in volumes. Review this page for additional environment variables that can be used.

The rest of the configuration is explained in the docker run section above, with the additional depends on tells Portainer to not start until Tailscale is running, and network_mode where we tell it to use ts-portainer service for it's networking.

Now we docker compose up -d which automatically targets the compose.yml to build the containers and pulls in variables stored in the .env. Watch as everything gets downloaded and created. When it's complete we visit the https://portainer.<yourmagicdns>.ts.net we should now see the following, success!

Enter your desired password, and then your license key on the next page. Then "Get Started", your "local" environment, and containers.

We see both our containers here running, with the option to inspect, see logs, etc. Of course, this can be done without the gui – feel free to dig around and see what other Portainer features exist!

With our environment set up successfully, I'll be writing another article about some Portainer features and containers that can be useful for the homelab.