Automated Docker image cleanup for Portainer — removes unused images older than 30 days via Portainer API, deployable as a Portainer Stack.
- Deletes Docker images unused for more than N days (default: 30)
- Skips images actively used by running or stopped containers
- Cleans up dangling images and build cache
- Dry run mode — simulate without deleting anything
- Fully configurable via environment variables
- Deployable as a Portainer Stack (no host access required)
- Persistent logs with automatic rotation
- Timezone-aware scheduling via cron
| Requirement | Version |
|---|---|
| Portainer CE | 2.14+ |
| Docker | 20.10+ |
.
├── Dockerfile # Alpine-based image (bash + curl + jq)
├── entrypoint.sh # Container entrypoint — sets up and runs crond
├── cleanup_portainer_images.sh # Main script — calls Portainer API
├── docker-compose.yml # Portainer Stack file
├── .env.example # Configuration template
└── .gitignore
- Open Portainer → click your username on the bottom-left sidebar
- Go to My account → scroll down to Access Tokens
- Click Add access token → enter a description (e.g.
image-cleanup) → confirm - Copy the token immediately — it is only shown once
The token looks like:
ptr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Open an environment in Portainer and look at the URL:
http://localhost:9000/#!/1/docker/containers
^
this number is your ENDPOINT_ID
Run this on your host to find the network name used by Portainer:
docker network ls | grep portainerUpdate the networks.portainer-network.name value in docker-compose.yml to match.
- Open Portainer → Stacks → Add stack
- Name it
image-cleanup - Choose Upload → select
docker-compose.yml - Scroll down to Environment variables and fill in:
| Variable | Example Value | Description |
|---|---|---|
PORTAINER_URL |
http://portainer:9000 |
Portainer URL reachable from inside the container |
PORTAINER_TOKEN |
ptr_xxxxx |
API token from Step 1 |
ENDPOINT_ID |
1 |
Environment ID from Step 2 |
MAX_AGE_DAYS |
30 |
Delete images older than N days |
CRON_SCHEDULE |
0 2 * * * |
Cron schedule (default: daily at 02:00) |
DRY_RUN |
true |
Set true first to simulate |
RUN_ON_START |
false |
Run cleanup immediately on container start |
TZ |
Asia/Jakarta |
Container timezone |
- Click Deploy the stack
| Variable | Default | Description |
|---|---|---|
PORTAINER_URL |
http://portainer:9000 |
Portainer base URL |
PORTAINER_TOKEN |
(empty) | API token — takes priority over username/password |
PORTAINER_USERNAME |
admin |
Fallback if no token is set |
PORTAINER_PASSWORD |
(empty) | Fallback if no token is set |
ENDPOINT_ID |
1 |
Portainer environment/endpoint ID |
MAX_AGE_DAYS |
30 |
Minimum image age in days before deletion |
CRON_SCHEDULE |
0 2 * * * |
Standard cron expression |
RUN_ON_START |
false |
Run once immediately when container starts |
DRY_RUN |
false |
Simulate without deleting |
KEEP_LOGS_DAYS |
90 |
Days to retain log files |
TZ |
Asia/Jakarta |
Timezone for cron scheduling |
| Schedule | Expression |
|---|---|
| Every day at 02:00 | 0 2 * * * |
| Every Sunday at 03:00 | 0 3 * * 0 |
| Every Monday–Friday at 02:00 | 0 2 * * 1-5 |
| Every 12 hours | 0 */12 * * * |
Container starts
└── entrypoint.sh
├── Sets timezone
├── Exports env vars to /app/.env_runtime
├── Registers cron job with CRON_SCHEDULE
├── (optional) Runs cleanup immediately if RUN_ON_START=true
└── Starts crond in foreground
On each cron trigger
└── cleanup_portainer_images.sh
├── Authenticates to Portainer API
├── Fetches all images from the endpoint
├── Fetches all containers (to detect image usage)
├── For each image:
│ ├── SKIP — if age < MAX_AGE_DAYS
│ ├── SKIP — if used by any container
│ └── DELETE — via DELETE /api/endpoints/{id}/docker/images/{id}
├── Prunes dangling images
├── Clears build cache
├── Rotates old logs
└── Prints summary
In Portainer, open the image-cleanup container and click Logs.
Or from the host:
docker logs portainer-image-cleanup
docker logs portainer-image-cleanup --followLog files are also stored in the cleanup-logs Docker volume:
docker run --rm -v portainer-image-cleanup_cleanup-logs:/logs alpine ls /logs# Clone and configure
cp .env.example .env
# Edit .env with your values
# Dry run (simulate)
docker compose run --rm -e DRY_RUN=true image-cleanup
# Live run
docker compose up -d- Never commit
.env— it is listed in.gitignore - Use API Token instead of username/password when possible
- The container does not require access to the Docker socket on the host
- All communication goes through the Portainer API over HTTP/HTTPS
MIT