Find what's using a port on macOS, and which project it belongs to
By Flavio
See which process holds a port on macOS, which project started it, and whether Docker published it, using the ports command.
A port on your Mac is busy, and lsof only gives you a process id. You still need the project folder, the command that started it, and whether Docker published the port. That is the difference between stopping a forgotten dev server and stopping something you still want.
lsof -i :3000 is the quick lookup. It prints the command and the PID. Find and kill the process using a port covers that command, kill, and the case where lsof prints nothing. Fix “EADDRINUSE: address already in use” on a local port is the follow-up when Node refuses to listen. This page stays on the question those two leave open: which project owns the port.
Port Pilot answers it with the ports command. A check can include the PID, the project directory, the command, the parent process, and the HTTP status. When Docker published the port, it adds the container name, the image, and the private port. A port number on its own is not that list.
What you need
The command-line tool runs on macOS and on Linux. You need Node.js 20 or newer. Node.js 24 is a suitable LTS release. Port scanning on macOS uses lsof. On Linux it uses ss or lsof. Docker support is optional. If you do not use Docker, the project and process fields still work.
Port Pilot needs no account, no API key, and no hosted service. It reads the listeners on the computer you run it on. Process details and HTTP health results are not sent anywhere. The command-line tool only saves optional aliases, in a configuration file outside the project.
After purchasing, download the source zip from your Polar receipt and unzip it. From that folder:
npm install
npm run verify
npm link
npm run verify is the check that the install is usable before you trust a later ports kill. npm link puts ports on your PATH. Run ports with no arguments and the terminal dashboard opens. You can delete nothing yet. The unzipped folder is the source you linked.
The purchase also includes a macOS menu bar app as SwiftUI source. It needs macOS 13 or later and Xcode or Swift, and you build it after the CLI is installed. You do not need it to answer “what is using this port”. It calls the same CLI. The steps below use ports in Terminal, which is the interface a script can repeat.
Check one port before you touch it
The number in the error is a listener, not a project name. Something accepted the bind. Until you know the directory and the command, stopping it is a guess. Skip the check and you can quit a server that is still serving a tab you care about, or a process that only looks idle.
Example 1: an Astro dev server on port 4321
The docs use this case. An Astro project says port 4321 is occupied. Inspect that port before stopping anything:
ports check 4321
The result can include its PID, project directory, command, parent process, and HTTP status. A web service can also show the local URL, the page title, and the response time. The screenshot on the Port Pilot page is that readable check, for a Next.js dev server on port 3000: process, project, framework, directory, process chain, and HTTP health.
The page does not print that screenshot as text. Here is example output with made-up values in those fields. It is not a paste of a real run:
port: 4321
project: recipe-box
framework: astro
directory: /Users/flavio/dev/recipe-box
command: node /Users/flavio/dev/recipe-box/node_modules/astro/astro.js dev
parent: npm run dev
http: 200
recipe-box is a stand-in name. Your directory is the one that matters. If that path is the repo you have open, the port belongs to that project. If it is some other checkout, you found a second dev server.
Add --json when a script needs the same details as data:
ports check 4321 --json
The page says the commands return structured output and meaningful exit codes. It does not print a sample JSON object, so treat --json as the same fields in a form a script can read, not as a second set of facts.
Run the check again after you start or stop a server. You get the listener that is there now. The CLI does not keep a history of past checks. The menu bar app can remember the last successful scan and show it while a refresh runs. The ports check command does not. A second check that names a different directory means a different process took the port.
Read the project, the chain, and Docker
A PID is one process. Dev servers rarely are. npm and the actual server sit in a chain, and Docker adds another hop: the port on the Mac, and a port inside the container. Kill only the PID from lsof and a parent can restart the child, or you stop the proxy and leave the container running. The chain is how you see that first.
The dashboard shows the port, project, framework, process, owner, memory use, uptime, command, and process ancestry. Open it with:
ports
The video on the app page shows this dashboard with six local dev servers. Those projects are fictional. One of them, webhooks-worker, answers HTTP 503. A listener can be up and still failing its HTTP check.
These keys work on the selected row. This list is the dashboard table from the docs:
Up/Down or j/k Select a service
/ Search process metadata
f Cycle service filters
s Cycle sorting
o Open its local URL
e Open the project directory
u, p, c, d Copy URL, port, command, or directory
K Preview stopping the process tree
r Refresh
q Quit
e opens the project directory, which is how you confirm a long path. o opens the local URL. / searches process metadata when you remember the project and not the port.
Example 2: a port Docker published
This is a different path from the Astro process in example 1. The listener on the Mac is Docker’s proxy. The project you care about is the container behind it.
When Docker published the port, Port Pilot adds the container name, the image, and the private port. The private port is the one inside the container. The port you passed to ports check is the one on the Mac. A container name means the process to think about is that container, not a node binary in a repo.
Example output again, made up, using only those Docker fields plus the process fields from a check:
port: 5432
command: com.docker.backend
container: recipe-box-db
image: postgres:16
private port: 5432
If those Docker lines are absent, Docker did not publish this port, or Docker is not available to the scan. Do not invent a container name from the command line. The check either has those fields or it does not.
The default list includes listeners with development evidence, such as a project, a framework, a runtime, or a Docker mapping. A published database port usually qualifies because of that mapping. A system listener with none of those signals stays off the default list until you ask for it, which is the next section.
Find it when you do not know the port
You often know the project and not the number. Guessing 3000, then 3001, then 4321 is how a second server hides. A check needs a port you already have. The list is how you get that number from the project, the framework, or the Docker mapping. Skip it and you only inspect the ports you thought to type. The list is the scan of development listeners, so you can search by what you remember.
ports list
Narrow it when the full list is noisy:
ports list --range 3000-9999
ports list --framework astro
--range keeps the ports in that interval. --framework astro keeps rows Port Pilot classified as Astro. Use one. A framework filter on a Docker-only port can hide the row you wanted, because that row’s evidence is the container mapping, not the Astro label.
ports list --json is the same list as structured records. ports watch --json emits a snapshot followed by service lifecycle events, for a script that needs to hear starts and stops. Watch keeps running. List returns once.
The dashboard refreshes on its own. r refreshes now. A second ports list is a new scan, not a cached copy of the first. If a server exited between the two, it is gone from the second list. If you still see it, it is still listening.
Aliases are the short name for a port you keep coming back to. They live in your user configuration, outside the project, so they survive a new clone of the repo:
ports alias api 8787
ports check api
ports open api
ports alias lists them. ports alias --remove api deletes one. Removing the alias does not stop the process and does not free the port. It only forgets the name.
See the listeners the default list hides
The default view is development services. System processes and anything Port Pilot could not identify are left out, so the list stays about projects. That filter is also how a real listener disappears. You ask “what is using this port”, the dev list is empty, and the port is still taken.
Example 3: the port is busy and the dev list does not show it
This is the miss. ports list prints your dev servers and skips the rest. A menu bar extra, a system service, or an unknown binary can hold the port and never appear there.
ports list --all
--all includes system and unknown listeners. In the menu bar app, Show all reveals the same excluded listeners. Try that before you decide the scanner is blind. The docs put it first: if a service is missing, try Show all or --all.
ports is-free is the yes-or-no form. It exits 0 when the port is free and 1 when it is occupied:
ports is-free 4321
Exit 1 with an empty default list means something is listening and it did not count as a development service. Follow it with ports check 4321 and, if the readable check is thin, ports list --all. Exit 0 means nothing is listening. An “address already in use” error in that case is the other tutorial’s problem, not a hidden project.
There is no timing figure in the docs for how long a scan takes. Leave the terminal on ports list until it prints. Do not stack a second scan on top of a guess about the first.
Stop the process only after the preview names it
Stopping is the part that can throw work away. A port number is not enough, which is why the check exists. Port Pilot shows the process, project, directory, command, PID, and process chain before it stops anything. The terminal dashboard and the command-line flow both require confirmation. --json does not count as a yes.
When you have read example 1 and the directory is the one you mean to quit:
ports kill 4321
Review the preview, then confirm. The default signal is SIGTERM. Descendants are signaled before the owner. Processes still around after the grace period can receive SIGKILL. That order matters. Signaling the parent first can leave children holding the port, or it can let a supervisor start them again. Descendants first is the tool’s attempt to take the tree down in an order that actually releases the port.
Stopping can interrupt unsaved work or a request in flight. Use the interactive form while you are learning the tool. Automation may pass --yes, and it should run ports check first. --yes skips the confirmation. It does not change which process tree is targeted.
The menu bar app uses the same rule. It shows a native confirmation screen, then sends the approved action through the command-line engine. There is not a second, more aggressive scanner behind the app.
There is no undo for a kill. SIGTERM asks the process to exit. SIGKILL after the grace period does not ask. Restart the server from the project directory if you stopped the wrong one. Nothing in Port Pilot brings the process back. ports wait 4321 --free waits until the listener disappears. ports wait 4321 --http 200 --timeout 60 waits until a server returns HTTP 200, or until 60 seconds pass. Use the first after a kill when a script must see the port go idle. Use the second after you start the server again.
A second ports kill on a port that is already free has nothing to stop. Check with ports is-free before you treat a repeated kill as a retry. If the port is occupied again, a new process took it. Read the preview. Do not assume it is the same PID.
If something looks wrong
If ports list has no row for a port that ports is-free says is occupied (exit 1), run ports list --all or turn on Show all. The default list drops system and unknown listeners on purpose.
If ports check shows a command and a PID but no project directory, Port Pilot did not tie the process to a project folder. Read the command and the parent before you stop it. A missing directory is not proof the port is free.
If the check names a container, image, and private port, the Mac-side process is the Docker publish. Stopping the proxy without looking at that container leaves the thing inside still running. The private port is the one in the container.
If you passed --yes and the wrong server died, that flag skipped confirmation. The preview was the place to catch the project name. Start that server again from its directory. The kill is not reversible.
If ports kill returns and the port is still taken, a descendant or a supervisor may still be exiting, or a new process bound it. ports wait 4321 --free waits for the listener to go away. Then run ports check 4321 again and read the new PID.
If npm link succeeded and the shell says ports: command not found, open a new terminal and run ports list. npm run verify in the unzipped folder is the install check that does not depend on that link.
If the menu bar app is empty while ports list in Terminal is not, the app is showing the last successful scan or the development filter. Show all is the control that reveals excluded listeners. The app and the CLI share one engine, so a row the CLI can see is not a different machine.
Get Port Pilot
Inspect local services, check their health, and stop the right process through a terminal dashboard, CLI, or macOS menu bar app.
See Port Pilot → · Read the docs · $20 including tax, with full source code.
Want me to talk about your product? You can sponsor this site.