Portain is one native window over the two things you actually want to see on a dev machine: every Docker container, grouped the way Compose grouped them, and every process sitting on a TCP port. Stop, restart, kill — then get back to work.
Free · MIT · macOS 14+ · universal binary
PORTS
DETAILS
That window is drawn, not screenshotted — click the sidebar, pick a row, fold a project. The containers and the ports are invented; the layout, the columns and the actions are the app's.
It shells out to docker and lsof and reads what comes back. It never installs a daemon, never registers a helper, and never changes anything you did not click.
Swift and SwiftUI, a universal binary, real sidebar materials and real SF Symbols. It launches like a Mac app because it is one.
No compose up, no builds, no image pulls, no volume editor. Seeing what is running and acting on it is the whole product.
Containers
docker ps hands you a flat list where a four-service project reads as four unrelated rows. Portain puts the Running ones above the Stopped ones and nests each one under its Compose project, as a Finder-style folder — expanded when it is up, collapsed when it is not.
Status dot, name, image, published ports. The service name shows inside a folder, so you read “postgres”, not “northwind-postgres”.
The port chips swap for a start or stop button under the pointer — the row keeps its exact footprint, so nothing jumps.
Hover a project header and the badge becomes one button that starts or stops every service in it.
A project with some services up and some down appears in both sections, which is exactly when you need to notice.
Start, Stop, Restart, Kill, Logs and Remove, with the container's ports table and live CPU and memory from docker stats.
A published mapping shows as 8080 → 3000/tcp with the URL beside it. An unpublished one is marked Internal and locked.
Hit Logs and the last two hundred lines open in a sheet over the window you were already in — monospaced, selectable, closed with Done.
Ports
Every listening TCP port on the machine in one native table: the port, the process holding it, its PID, whether it is bound to localhost or to every interface, and who owns it. Select one and free it — Terminate sends SIGTERM, Force Kill sends SIGKILL, and both ask first.
A port published by a container shows the container's name rather than com.docker.backend, with a docker badge and a link across to it.
A padlock means 127.0.0.1 only. A globe means anything on your network can reach it — which is worth knowing before you leave the café.
Multi-select the table and terminate or force-kill the lot, or copy every PID in one go.
lsof reports one row per descriptor. Portain folds a dual-stack bind into a single row labelled TCP46 rather than showing it twice.
macOS listens on 5000 and 7000 out of the box, which is why your dev server will not bind and why no amount of restarting fixes it. Portain names the process instead of leaving you to guess — that one is ControlCenter, and the fix is a checkbox in System Settings, not a kill.
How it works
There is no API client, no socket, no privileged helper and nothing running when the window is closed. Portain runs the commands below, parses the output and draws it. That is the entire mechanism, and it is why an audit of this app is a short read.
It also means the failure modes are ones you already understand. Docker not installed, daemon not up, a process owned by root that will not take a signal from you — each one says so plainly instead of spinning.
docker ps -a --format '{{json .}}'docker stats --no-streamdocker start / stop / restart / kill / rmdocker logs --tail 200lsof -nP -iTCP -sTCP:LISTEN -Fkill -TERM · kill -KILLMenu bar
A companion that lives in the status bar with no dock icon, lists every active port, and kills the one you click — after asking. Docker's ports are listed underneath and deliberately inert, because the thing to stop is the container, not the proxy.
Built from source for now — swift run PortainMenuBar. The released .app bundles the main window only.
Terminal companion
bunx portain gives you the containers and the ports in a terminal, with the same grouping, the same actions and the same confirmations. Handy on a box with no screen, and handier still when the window would be a context switch.
PORTAIN [1 CONTAINERS] 2 PORTS ┌─Containers · 7 up / 13──────────────────────────────────────────────────────────────────────┐ ┌─Details────────────────────────┐ │ ▾ RUNNING 7 █│ │ │ │ ▾ northwind (4) █│ │ helio-postgres │ │ ● northwind-postgres postgres:16-alpine 0.0.0.0:5432->5432/tcp █│ │ │ │ ● northwind-redis redis:7-alpine 0.0.0.0:6379->6379/tcp █│ │ Up 6 hours (healthy) │ │ ● northwind-api northwind-api:latest 0.0.0.0:8080->3000/tcp █│ │ │ │ ● northwind-worker northwind-api:latest — █│ │ image postgres:17-alpine │ │ ▾ helio (2) █│ │ id 89ba0c44aaf7 │ │ ❯ ● helio-postgres postgres:17-alpine 0.0.0.0:5433->5432/tcp █│ │ ports 0.0.0.0:5433->5432/tcp │ │ ● helio-mailpit axllent/mailpit:latest 0.0.0.0:8025->8025/tcp, 1025/… █│ │ │ │ ▾ riogrande (1) ▀│ │ │ │ ● riogrande-postgres postgres:16-alpine 0.0.0.0:5434->5432/tcp │ │ │ │ ▾ STOPPED 6 │ │ │ │ ▾ turnstile (3) │ │ │ │ ● turnstile-payload turnstile-cms:latest — │ │ │ │ ● turnstile-postgres postgres:16-alpine — │ │ │ │ ● turnstile-minio minio/minio:latest — │ │ │ │ ▾ Standalone (3) │ │ │ │ ● meilisearch getmeili/meilisearch:… — │ │ │ │ ● mailhog mailhog/mailhog:latest — │ │ │ └─────────────────────────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────┘ Updated 2:33:42 PM s start/stop · r restart · d remove · f fold · Shift+f section v1.1.2
PORTAIN 1 CONTAINERS [2 PORTS] ┌─Listening ports · 16────────────────────────────────────────────────────────────────────────┐ ┌─Details────────────────────────┐ │ ❯ 631 launchd 415 ::1 root │ │ │ │ 3000 node 31804 127.0.0.1 dev │ │ :631 │ │ 5000 ControlCenter 612 * dev │ │ │ │ 5173 node 48120 127.0.0.1 dev │ │ launchd │ │ 5432 com.docker.backend 2184 * DOCKER │ │ │ │ 5433 com.docker.backend 2184 * DOCKER │ │ pid 415 │ │ 5434 com.docker.backend 2184 * DOCKER │ │ user root │ │ 6379 com.docker.backend 2184 * DOCKER │ │ addr ::1 │ │ 7000 ControlCenter 612 * dev │ │ type TCP6 │ │ 8000 python3 52011 * dev │ │ │ │ 8025 com.docker.backend 2184 * DOCKER │ │ │ │ 8080 com.docker.backend 2184 * DOCKER │ │ │ │ 9229 node 48120 127.0.0.1 dev │ │ │ │ 11434 ollama 60432 127.0.0.1 dev │ │ │ │ 24678 node 31804 127.0.0.1 dev │ │ │ │ 49152 rapportd 7714 * dev │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────┘ Updated 2:33:52 PM x terminate · Shift+x force kill v1.1.2
lsof read, sorted by port. Docker's listeners are labelled rather than attributed to your account.PORTAIN 1 CONTAINERS [2 PORTS] ┌─Listening ports · 16────────────────────────────────────────────────────────────────────────┐ ┌─Details────────────────────────┐ │ 631 launchd 415 ::1 root │ │ │ │ 3000 node 31804 127.0.0.1 dev │ │ :11434 │ │ 5000 ControlCenter 612 * dev │ │ │ │ 5173 node 48120 127.0.0.1 dev │ │ ollama │ │ 5432 com.docker.backend 2184 * DOCKER │ │ │ │ 5433 com.docker.backend 2184 * DOCKER │ │ pid 60432 │ │ 5434 com.docker.backend 2184 * DOCKER │ │ user dev │ │ 6379 com.docker.backend 2184 * DOCKER │ │ addr 127.0.0.1 │ │ 7000 ControlCenter 612 * dev │ │ type TCP4 │ │ 8000 python3 52011 * dev │ │ │ │ 8025 com.docker.backend 2184 * DOCKER │ │ │ │ 8080 com.docker.backend 2184 * DOCKER │ │ │ │ 9229 node 48120 127.0.0.1 dev │ │ │ │ ❯ 11434 ollama 60432 127.0.0.1 dev │ │ │ │ 24678 node 31804 127.0.0.1 dev │ │ │ │ 49152 rapportd 7714 * dev │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────┘ CONFIRM? y or Enter / any key cancel x terminate · Shift+x force kill v1.1.2
Those three are real frames: the TUI was run inside a pseudo-terminal against a stubbed docker and lsof, and every cell it painted was recorded with the colour it painted it in. The machine it is looking at does not exist.
Fine print
Better to read this now than to find out at the download.
Notarising needs a paid Apple Developer ID, so the released build is not notarised and macOS blocks it once. System Settings ▸ Privacy & Security ▸ Open Anyway, or xattr -dr com.apple.quarantine /Applications/Portain.app. On macOS 15+ the old right-click ▸ Open trick no longer works.
No daemon, no background agent, no helper install. If Docker is not running the Containers view says so and the Ports view carries on working — lsof does not care.
The Ports view is lsof -sTCP:LISTEN. UDP, established connections and unix sockets are not in it.
Killing something owned by another user or by the system needs privileges Portain does not ask for. It reports the failure rather than escalating.
swift run PortainMenuBar runs it. The released .app bundles the main window only, so the menu bar companion is not in the download yet.
No compose up, no image pulls, no volume or network editing, no builds. Portain shows you what is running and gets out of the way — the rest is still docker in a terminal.
Portain is free and open source. Drag it to Applications, allow it once in Privacy & Security, and it never asks you for anything again.