Portainer: Docker Container Management That Actually Works
I was managing Docker exclusively through CLI for years. Log inspection meant scrolling through thousands of lines, and checking stats across 5 servers was SSH hell. Portainer changed all that. Here's my setup managing containers across a home lab and a VPS.
Problem
Had Portainer running on my main server, wanted to add a second VPS to the same dashboard.
Deployed the Portainer Agent on the VPS, but Portainer couldn't connect - "Connection refused".
The agent was running (verified with docker ps), but no go.
Error: Failed to add environment: Connection refused
What I Tried
Attempt 1: Used the server's private IP - failed (servers on different networks).
Attempt 2: Used public IP with port 9001 - timed out (firewall blocking).
Attempt 3: Exposed port 9001 in Docker compose - still blocked by ufw.
Actual Fix
The agent needs port 9001/tcp exposed AND the firewall must allow it. Also, for security, I set up an SSH tunnel to the agent instead of exposing it publicly. Portainer supports SSH connections.
# Option 1: Direct connection (expose port 9001)
# On remote server, run agent with exposed port:
docker run -d \
-p 9001:9001 \
--name portainer-agent \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
portainer/agent:latest
# Allow through firewall (UFW example)
sudo ufw allow 9001/tcp
# Option 2: SSH tunnel (more secure, no port exposure)
# In Portainer UI: Add Environment
# - Environment type: Agent
# - Environment URL: ssh://user@remote-server-ip
# - Port: 9001 (agent's internal port)
# - SSH key: Upload your private key or password
# No firewall changes needed - uses SSH port 22
Problem
Clicked on "Templates" in Portainer, the list never loaded. Just spun forever with a loading spinner. Could deploy containers manually, but the template feature (my main use case) was broken.
What I Tried
Attempt 1: Refreshed the page multiple times - no change.
Attempt 2: Restarted the Portainer container - still failed.
Attempt 3: Checked Portainer logs - showed "fetching templates..." but no errors.
Actual Fix
The default template URL (portainer.io templates) was timing out. Portainer allows custom template definitions. I either needed to: 1) Use a local template file, or 2) Host my own template repository, or 3) Define templates manually in Portainer's App Templates settings.
# Create custom template file (templates.json)
{
"version": "2",
"templates": [
{
"title": "Nginx",
"description": "High performance web server",
"logo": "https://example.com/nginx.png",
"image": "nginx:latest",
"ports": ["80:80", "443:443"],
"volumes": ["/etc/nginx/conf.d", "/var/www/html"]
},
{
"title": "PostgreSQL",
"description": "Object-relational database",
"logo": "https://example.com/postgres.png",
"image": "postgres:15",
"env": [
{
"name": "POSTGRES_PASSWORD",
"label": "Postgres Password",
"default": "changeme"
}
]
}
]
}
# In Portainer: Settings → App Templates
# Use custom URL: file:///templates/templates.json
# Or host the file and use: https://your-domain.com/templates.json
Problem
Uploading a docker-compose.yml file through Portainer's web editor. Clicked "Deploy the stack",
got "Failed to deploy stack: invalid reference format".
The same docker-compose.yml worked perfectly when deployed from CLI with docker-compose up.
What I Tried
Attempt 1: Removed comments from docker-compose.yml - didn't help.
Attempt 2: Simplified the compose file to basic service - still failed.
Attempt 3: Tried pasting the content instead of uploading - same error.
Actual Fix
The issue was hidden characters in the web editor - specifically, smart quotes and em-dashes from copying documentation. Portainer's parser is stricter than docker-compose CLI. Fixed by: 1) Using a text editor to ensure plain ASCII, or 2) Uploading the file instead of pasting, or 3) Using Portainer's stack upload from git repository.
# Instead of pasting into web editor:
# Option 1: Upload file directly
# In Portainer: Stacks → Add stack → Upload from file
# Select your docker-compose.yml
# Option 2: Use Git repository
# In Portainer: Stacks → Add stack → Git repository
# Repository URL: https://github.com/username/repo
# Compose path: docker-compose.yml (in repo)
# Option 3: Fix the text editor issue
# Use VS Code or similar to ensure no smart quotes
# Find: "" Replace: " (straight quotes)
# Find: — Replace: -- (em dash to hyphens)
What I Learned
- Lesson 1: SSH tunneling to agents is more secure than exposing port 9001 publicly - use it for remote servers.
- Lesson 2: The default template repository can be slow/unreliable - host your own templates for production use.
- Lesson 3: Portainer's YAML parser is stricter than docker-compose CLI - hidden characters cause silent failures.
- Overall: Portainer is excellent for visual management but don't abandon CLI entirely. Some operations (complex debugging, emergency recovery) are still faster via command line.
Production Setup
Complete setup for Portainer with agents across multiple servers.
# 1. Main Portainer Server
docker run -d \
-p 9443:9443 \
--name portainer \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
cr.portainer.io/portainer/portainer-ce:latest
# Access at: https://your-server-ip:9443
# Default admin account required on first login
# 2. Portainer Agent (on remote Docker hosts)
docker run -d \
-p 9001:9001 \
--name portainer-agent \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
portainer/agent:latest
# 3. Add Agent to Portainer
# In Portainer UI: Environments → Add environment
# Select: Agent
# Environment URL: agent-server-ip:9001
# Or use SSH: ssh://user@agent-server-ip
# 4. Edge Agent (for IoT/remote devices with unstable connectivity)
# On edge device:
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
--name portainer-edge-agent \
--restart=always \
portainer/agent:latest
# In Portainer UI: Environments → Add edge environment
# The agent will poll Portainer periodically (no inbound connection needed)
# Docker Compose for Portainer stack
version: '3.8'
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
ports:
- "9443:9443"
- "9000:9000"
- "8000:8000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
networks:
- proxy-network
agent:
image: portainer/agent:latest
container_name: portainer-agent
restart: unless-stopped
ports:
- "9001:9001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/volumes:/var/lib/docker/volumes
networks:
- proxy-network
volumes:
portainer_data:
networks:
proxy-network:
external: true
Monitoring & Debugging
Key commands and monitoring tips for Portainer:
Essential Commands
# View Portainer logs
docker logs portainer -f
# Restart Portainer (preserves data)
docker restart portainer
# Backup Portainer data
docker run --rm \
-v portainer_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/portainer-backup.tar.gz /data
# Restore from backup
docker run --rm \
-v portainer_data:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/portainer-backup.tar.gz
# Check agent connectivity
# In Portainer UI: Environments → Select environment → Status
Red Flags to Watch For
- Agents showing "Disconnected" status - check network connectivity and firewall rules
- Template loading takes >30 seconds - consider using custom template repository
- Portainer container restarting - check disk space and Docker daemon health
- Stack deployment fails silently - use CLI to validate compose files first