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.
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/portainerHere’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
edgenetwork 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
edgenetwork—will become very common in our setup. Each publicly accessible service (Jellyfin, Ghost, etc.) will join the sameedgenetwork 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
edgenetwork 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 portainerYou’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:
- Create an admin username and password
- 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
- Visit:
https://portainer.wody.duckdns.org - Open DevTools → Network → Response Headers
- Confirm that all custom headers appear
For extra validation, visit securityheaders.com.

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.