Running Docker on Windows WSL2/Ubuntu (Part 4) – Installing & Hardening Portainer

Learn how to install Portainer on your Docker WSL2 setup and secure it with SSL and custom headers using Nginx Proxy Manager. Step-by-step guide with Docker Compose.

Running Docker on Windows WSL2/Ubuntu (Part 4) – Installing & Hardening Portainer

Introduction: Why Portainer?

Personally, I prefer managing Docker via command line and Vim—it gives me finer control and keeps me closer to the system. But having Portainer installed is still incredibly handy. Every now and then, I find myself logging in for quick visual checks, stack deployments, or debugging.

There are many web-based tools for managing Docker containers—such as:

  • Yacht
  • DockStation
  • Lazydocker (with a TUI interface)
  • Rancher
  • Portus

Out of these, the most widely adopted and battle-tested is Portainer.
It’s known for being lightweight, intuitive, and reliable.

Portainer comes in two editions:

  • Portainer CE (Community Edition) – free to use, perfect for individuals and hobbyists
  • Portainer Business – paid, with advanced features like RBAC (role-based access control), team management, enhanced Docker Swarm/Kubernetes support, and enterprise-grade assistance

👉 For personal or home-lab users, Portainer CE is more than enough.

In this post, we’ll go through installing Portainer CE on WSL2/Ubuntu with Docker Engine and then hardening it with SSL and custom headers via Nginx Proxy Manager. This way, you get the convenience of a GUI without compromising on security.


Step 1: Installing Portainer

1. Create Folder Structure

Keep things tidy by placing Portainer in its own project folder inside ~/docker:

mkdir -p ~/docker/portainer/data
cd ~/docker/portainer

Here’s the structure:

~/docker/portainer/
 ├─ docker-compose.yml
 └─ data/        (Portainer’s persistent data)

2. Docker Compose File

Create docker-compose.yml in the ~/docker/portainer folder:

services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: unless-stopped
    ports:
      - "9000:9000"   # no need to expose once you set-up the Proxy Hosts
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/data
    networks:
      - edge

networks:
  edge:
    external: true

👉 Notes on the edge network

  • The edge network is a shared external Docker bridge network that we created earlier.
  • By attaching Portainer to it, you’re ensuring that NPM (Nginx Proxy Manager) and any other container on the same network can directly route traffic to Portainer.
  • This pattern—attaching a single container to the edge network—will become very common in our setup. Each publicly accessible service (Jellyfin, Ghost, etc.) will join the same edge network so NPM can “see” it.
  • In the next part, we’ll go one step further: when you have multiple containers inside the same compose stack (e.g., a web app + database), you’ll use both a private internal network and the shared edge network together. That way, internal services (like the database) stay hidden, while only the frontend app is exposed to NPM.


3. Start the Portainer Container

With your docker-compose.yml in place, it’s time to launch Portainer and verify that everything is running correctly inside your WSL2 + Docker Engine environment.

Start Portainer in Detached Mode

Run the following command from inside your ~/docker/portainer directory:

docker compose up -d

The -d flag tells Docker to run the container in the background (detached mode), so it won’t block your terminal session. This way, Portainer continues running even if you close the window.


Check Container Status

Confirm that Portainer is active:

docker ps

You should see an entry like this:

CONTAINER ID   IMAGE                            STATUS          PORTS
abc123456789   portainer/portainer-ce:latest    Up 15 seconds   9000/tcp

If the status shows Up, your Portainer container is now running. 🚀


Monitor Logs

Logs are the best way to check what’s happening inside a container. Follow Portainer’s logs with:

docker logs -f portainer

You’ll see messages confirming that the Portainer service is starting and binding to its internal port.
💡 Use CTRL+C to stop following logs without shutting down the container.


Verify Networking (edge)

Because Portainer is attached to the shared edge network, you should confirm that it’s properly connected. Run:

docker network inspect edge

Look under the Containers section—you should see Portainer listed there.
This ensures Portainer is discoverable by Nginx Proxy Manager (NPM) and can be reached cleanly via a subdomain once you configure the proxy host.


Why These Checks Matter

These verification steps are more than just formality. They ensure that:

  • Docker successfully pulled and started the Portainer image.
  • Your container is alive and responding.
  • Networking is configured correctly so Portainer integrates with the rest of your home server stack.

By confirming this now, you’ll save yourself from debugging headaches later when adding more complex containers like Ghost, Jellyfin, or Nextcloud.


👉 With Portainer up and running, you’ve taken a major step toward managing Docker visually in your home server project. In the next section, we’ll explore how to harden access by putting Portainer behind Nginx Proxy Manager with SSL.


Step 2: First Login

Open Portainer at:

👉 http://<your-wsl-ip>:9000

On the first screen:

  1. Create an admin username and password
  2. Point Portainer to the local Docker socket (/var/run/docker.sock)

Once inside, you’ll see the dashboard where you can manage containers, networks, and stacks.

⚠️ Important: Do not keep Portainer accessible on port 9000 forever. The next step is to hide it behind Nginx Proxy Manager with SSL.


Step 3: Harden Portainer with NPM

1. Create Proxy Host

Go to NPM → Hosts → Proxy Hosts → Add Proxy Host.

Fill in:

  • Domain Names: portainer.wody.duckdns.org (or your own domain)
  • Forward Hostname/IP: portainer (container name)
  • Forward Port: 9000
  • Scheme: http

SSL tab:

  • Select Request a new SSL certificate
  • Enable Force SSL and HTTP/2
  • Leave HSTS disabled until everything works

Save.

Now, you can log into Portainer securely at:
👉 https://portainer.wody.duckdns.org

Finally, go back to your docker-compose.yml and comment out/remove the 9000:9000 line. Once redeplay the stack and no problem with Enable HSTS option. From now on, only NPM exposes Portainer.


2. Custom Security & Cache Headers

To add another layer of protection, inject these headers via NPM → Proxy Host → Advanced → Custom Headers:

# === Security Headers ===
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# HSTS only available on HTTPS. Must check Force SSL ON
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# === Cache Control (no-cache) ===
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
  • Security Headers → stop clickjacking, force HTTPS, block referrer leaks
  • Cache Headers → ensure no admin data is cached by browsers or proxies

With these applied, Portainer becomes much harder to attack via common web exploits.


3. Testing the Setup

  1. Visit: https://portainer.wody.duckdns.org
  2. Open DevTools → Network → Response Headers
  3. Confirm that all custom headers appear

For extra validation, visit securityheaders.com.

Analyse your HTTP response headers
Quickly and easily assess the security of your HTTP response headers

Final Thoughts

At this point, Portainer is:

  • Installed and running in Docker
  • Hidden behind Nginx Proxy Manager
  • Protected with SSL and custom security/cache headers

This is your secure GUI for Docker. You can now manage containers visually without sacrificing safety.

Now that Portainer is secured and ready, it’s time to deploy a real-world application: a Ghost blog.

In the next part, we’ll cover:

  • Setting up Ghost with Docker Compose
  • Understanding how multi-container stacks (Ghost + database) use both private and shared networks
  • Learning how this pattern can be reused for other applications in your home server

👉 Next Up in Part 5: Deploying Ghost with Docker Compose and Multi-Network Setup.