Port 8080 Already in Use — Spring Boot Fix
Run lsof -ti:8080 | xargs kill -9 to free the port, or set server.port=8081 to move your app.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Java 17 and a Spring Boot project that runs locally
- ✓Terminal access with lsof or ss installed
- ✓Basic comfort editing application.properties
- Your app failed because something already holds TCP port 8080, so embedded Tomcat can't bind — it's a conflict, not broken code
- Find the holder with lsof -i :8080 or ss -ltnp 'sport = :8080', then stop it with kill
, escalating to kill -9 only for stuck JVMs - Move your app instead with server.port=8081 in application.properties or SERVER_PORT=8081 as an env var
- In tests use @SpringBootTest(webEnvironment = RANDOM_PORT) so each run gets a free port and parallel builds never clash
Think of port 8080 as a single parking space in front of your shop. Your Spring Boot app is a delivery van that needs that exact space. If another van is already parked there — maybe your own van from this morning that you forgot to move — the new van can't park, and the city (your operating system) turns it away. The fix is simple: find whose van is in the space and move it, or tell your van to park in space 8081 instead.
You hit run, the Spring Boot banner prints, and then the startup dies: Port 8080 was already in use. No controller loads, no actuator endpoint responds, and the log buries the real cause two stack frames deep. Every Spring developer meets this error, usually on a deadline, usually when a demo is due in ten minutes.
The error looks scary but it's good news: your code compiled, the context started loading, and the embedded Tomcat got all the way to binding its socket. The OS simply said no, because one socket already listens on 8080. Something else owns the port — a previous run you forgot, a teammate's service, a Docker container, or a test JVM still shutting down.
This guide walks you through the full fix cycle. You'll learn to read the BindException and tell it apart from lookalikes, find the exact process with lsof or ss, kill it safely (or move your app instead), configure server.port and SERVER_PORT without profile surprises, make tests collision-proof with RANDOM_PORT, and untangle Docker publish conflicts. By the end, a bind failure will cost you thirty seconds instead of thirty minutes.
Reading the BindException: What Port 8080 Already in Use Actually Means
The full error reads Web server failed to start. Port 8080 was already in use, usually followed by Caused by: java.net.BindException: Address already in use. Read it inside-out: the outer line is Spring's friendly wrapper, the Caused by line is the OS telling you the bind syscall was rejected. Everything above the wrapper — bean creation traces, context messages — is noise. Your app got all the way through classloading and context refresh; only the final socket bind failed.
Don't confuse this with its cousins. Connection refused means nothing listens on the port you called — the opposite problem. Address already in use with no Spring wrapper usually comes from plain Java socket code or a test that opens raw sockets. And if the message names a different port, some config already moved your server and the clash followed it — check server.port resolution before hunting processes.
The key insight: this error is always about the environment, never about your business logic. No controller, service, or repository code can cause it. So resist the urge to rebuild, revert, or rebase. The fastest path is a single command that names the holder, and the next section shows you exactly which one to run on your OS.
Finding the Holder: lsof and ss Commands That Name the Process
Your first move is always the same: ask the OS which process owns the listening socket on 8080. On macOS and most Linux desktops, lsof -i :8080 prints the COMMAND, PID, and USER — everything you need. The -sTCP:LISTEN variant filters out transient client connections so you see only the true holder. When you need just the number for a script, lsof -ti:8080 prints the bare PID, which pipes straight into kill.
On servers and minimal containers where lsof isn't installed, ss -ltnp 'sport = :8080' is your tool. The -l shows listening sockets, -t restricts to TCP, -n skips slow DNS lookups, and -p reveals the owning process in the users field. It runs instantly even with thousands of connections, which matters on busy hosts where lsof crawls. Older boxes may still have netstat; the grep form works but parses less reliably, so prefer ss wherever it exists.
Read the output before acting. A COMMAND of java with your project's path means your own previous run — safe to kill. Anything else (a proxy, a media server, a colleague's tool) means you should move your app instead of killing theirs. This thirty-second read prevents the classic blunder of kill -9ing someone else's debug session.
Killing It Safely: Stop, Kill, and Verify the Port Is Free
Killing is a two-step escalation, not a single hammer blow. Start with a plain kill <PID> (SIGTERM): Spring Boot catches it, runs shutdown hooks, closes the context gracefully, and releases the socket. Wait about five seconds, then re-run your lsof or ss check. An empty result means the port is free and you can relaunch with confidence.
If the process survives — common with a JVM stuck in a shutdown hook or a debugger holding threads — escalate to kill -9 <PID> (SIGKILL). The kernel reaps it immediately with no cleanup, so in-flight requests die and temp files may linger. That's acceptable for a local dev process but never your first choice on shared infrastructure. After a SIGKILL, always re-verify: sockets can sit in TIME_WAIT briefly, and relaunching a half-second too early reproduces the exact error you just fixed.
The lsof -ti:8080 | xargs kill -9 one-liner is popular and dangerous in equal measure: it kills whatever holds the port without asking. Use it only on your own laptop where you're certain the holder is your stale JVM. On any shared box, name the PID explicitly so the command documents your intent and your shell history shows what you actually killed. A few extra seconds of verification beats another failed launch and another round of head-scratching.
Moving Your App: server.port, SERVER_PORT, and Profiles
Sometimes killing isn't an option — the holder is a teammate's service or a tool you need. Moving your app takes one line: server.port=8081 in application.properties. Spring Boot reads it at startup and the embedded container binds the new port instead. The startup log confirms with Tomcat started on port 8081, which is the proof you should always look for rather than assuming the edit took effect.
But properties are only one layer. The full precedence chain is: command-line arguments (--server.port=8083) win over the SERVER_PORT env var, which wins over profile-specific files like application-dev.properties, which win over plain application.properties. When your edit looks ignored, something higher in the chain is overriding it — a stale export SERVER_PORT=8080 in your shell profile is the usual suspect, and echo $SERVER_PORT exposes it in one second.
For teams, make ports explicit per service: 8081 for the API, 8082 for auth, and so on, recorded in the README. Env vars are the cleanest vehicle for this because they vary per machine without touching committed files. Containers and systemd units should each set SERVER_PORT explicitly so no service ever depends on the 8080 default surviving contact with reality.
Tests That Stop Fighting: RANDOM_PORT and Parallel Builds
Tests are the stealthiest source of bind conflicts. A suite annotated with DEFINED_PORT (or the default MockEnvironment-free setup that still binds 8080) works fine alone but explodes the moment your build runs forks in parallel: two JVMs race for the same fixed port, one wins, and the loser fails with the familiar BindException. On CI the failure looks flaky because which fork wins varies run to run.
The fix is `WebEnvironment.RANDOM_PORT`: Spring Boot asks the OS for a free ephemeral port per test context, so parallel forks never collide by construction. You read the assigned port with @LocalServerPort and build request URLs from it instead of hard-coding localhost:8080. This keeps every assertion identical while removing the shared mutable state — the fixed port — that caused the race.
Reserve DEFINED_PORT for the rare test that genuinely needs a stable URL, like a contract test asserting on callback addresses, and mark those tests so they never run in parallel with anything. For everything else, make RANDOM_PORT the team default and enforce it in review: any new test class binding a fixed port should have to justify itself. Flaky-port CI failures are a tax you can legislate away in one pull request.
Docker and Lingering Runs: Publish Conflicts and Zombie JVMs
Docker adds a layer of misdirection: each container has its own network namespace, so 8080 inside two containers is perfectly legal — the clash happens at the host publish step. When two services both declare -p 8080:8080 or the same compose ports entry, the second start fails with port is already allocated. Spring Boot never even launches, which sends you hunting JVM causes for a Docker problem.
Diagnose with docker ps --format '{{.Names}} {{.Ports}}': it lists every container's host mapping in one glance, and the duplicate 0.0.0.0:8080 jumps out immediately. The fix is a distinct host port per service — -p 8081:8080 keeps the container's internal 8080 untouched while exposing it safely. In compose files, audit the ports section the same way; host ports must be unique even when container ports repeat.
The twin trap is the lingering local run: you containerized the app but the old ./mvnw spring-boot:run JVM is still alive on the host's 8080, so the container's publish fails. When docker ps shows no duplicate yet the publish still fails, fall back to lsof on the host — the holder is a zombie JVM from before you adopted Docker, and one kill closes the case. Make the host check part of your container debugging habit and publish conflicts will stop surprising you.
The Friday Deploy That Failed Three Times Over One Forgotten Debug Session
- A bind failure is an environment fact, not a code defect — check the port before you rebuild. Two wasted redeploys cost this team 40 minutes that one ss command would have saved.
- Every long-lived port assignment should live in exactly one managed place (systemd unit, compose file, or manifest). A human starting a process by hand bypasses all of it.
- Pre-flight checks beat post-mortems: a five-second port probe in the deploy script turns a 40-minute outage into a loud, instant, self-explaining failure.
| File | Command / Code | Purpose |
|---|---|---|
| port-8080-diagnose.sh | ./mvnw spring-boot:run | Reading the BindException |
| find-port-holder.sh | lsof -i :8080 | Finding the Holder |
| free-port-8080.sh | kill 48213 | Killing It Safely |
| application.properties | server.port=8081 | Moving Your App |
| OrderApiTest.java | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) | Tests That Stop Fighting |
| docker-port-fix.sh | docker ps --format '{{.Names}} {{.Ports}}' | Docker and Lingering Runs |
Key takeaways
Common mistakes to avoid
5 patternsKilling a random PID without checking what holds the port
Setting server.port in the wrong file or profile
Starting a second instance while the first run is still alive
Publishing every container's 8080 to the host's 8080
Using DEFINED_PORT in tests that run in parallel builds
Interview Questions on This Topic
What does 'Port 8080 was already in use' actually mean?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring. Mark it forged?
5 min read · try the examples if you haven't