What you'll learn
- Where to find logs for a deployed app: the host's dashboard, docker logs, or journalctl
- Why build logs and runtime logs are separate streams, and which one answers which failure
- How to walk a stack trace to the first line of your own code
- What log levels are for, and why logging everything at once buries the one line that matters
- What to log at boundaries and failures, and what must never end up in a log
on this page
On your laptop, a bug has nowhere to hide. The app is running right there in your terminal, you can sprinkle console.log lines and rerun in two seconds, and worst case you attach a debugger and step through the whole thing in slow motion.
Then you deploy, and all of that is gone. The process now runs on a machine you will never see, restarted by software you do not control, and when it breaks you cannot poke it. The only thing it leaves behind is the log: a running record of everything the app said while it was alive. In production, the log is the witness, and usually the only one.
The good news is that reading logs is a learnable, mostly mechanical skill. Three questions cover the bulk of it: where do the logs physically live, how do you read a stack trace once you find one, and what should your own code be saying into the record so future-you has something to work with.
Where the logs actually live
This part trips people up before any actual debugging starts, so let’s sort out the geography first. For a first real app there are three places that matter.
The hosting dashboard
If you deployed to a host like Vercel, Netlify, Railway, or Render, the logs are in the project’s dashboard, and there are two separate streams with two different jobs.
Build logs are everything printed while the host was building your project: npm ci, the framework compiling, type errors, the dreaded “module not found.” This stream exists even when the app never started, because building happens before running.
Runtime logs are everything your app prints after the deploy succeeds and the process is live: every console.log and console.error, every crash, every request your server decides to mention.
They are separate because they come from separate processes. The build runs once, in a throwaway environment that gets deleted afterward; the runtime is your actual server, alive right now. A spotless build log says nothing about whether the app works, and a runtime log cannot explain why a deploy never went out. Knowing which stream answers which question is half the skill, and there’s a thirty-second triage for it near the end of this guide.
One aside: on a purely static site there is almost no runtime log, because after the build nothing of yours is running. Files are just being served. If your project is HTML and CSS on Netlify, the build log is the whole story.
docker logs, for containers
A well-behaved container does not write log files. It prints to standard output, the stream that would be your terminal if you ran the process by hand, and Docker records all of it. docker logs plays the recording back:
docker logs --tail 100 my-api The full history is usually thousands of lines. Start with the last hundred; a crash is almost always near the end.
docker logs -f my-api TypeError: Cannot read properties of undefined (reading 'email')
at sendWelcomeEmail (/app/src/services/email.js:14:27)
at createUser (/app/src/routes/users.js:31:18) -f follows the log live. Reproduce the bug in another window and watch the trace land in real time. Ctrl+C stops the following, not the container.
docker logs --since 15m my-api Only the last fifteen minutes, which is usually the exact window you care about.
If you’re running things through Compose, docker compose logs -f api does the same job for a single service while the rest of the stack stays quiet. And when the problem is the container itself rather than your code, Docker debugging picks up from here.
journalctl, for a VPS
If you rent a VPS (a Virtual Private Server, meaning a plain Linux box that is entirely your problem) and run your app as a systemd service, the system journal collects everything the service prints. journalctl is the playback tool:
# Everything your service has ever logged
journalctl -u myapp
# Follow live, same idea as docker logs -f
journalctl -u myapp -f
# Just the recent past
journalctl -u myapp --since "30 minutes ago"
The -u flag picks the unit, which is systemd’s word for a managed service. Leave it off and you get the whole machine’s diary, kernel gossip included, which is a lot. Depending on how your user account is set up, you may need sudo in front of these.
How to read a stack trace
Sooner or later the log hands you one of these, and the first instinct is to bounce off it. Don’t. A stack trace is the most honest thing in the entire log; you only need to know which parts to ignore.
TypeError: Cannot read properties of undefined (reading 'email')
at sendWelcomeEmail (/app/src/services/email.js:14:27)
at createUser (/app/src/routes/users.js:31:18)
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
at next (/app/node_modules/express/lib/router/route.js:149:13)
at Route.dispatch (/app/node_modules/express/lib/router/route.js:119:3)
at /app/node_modules/express/lib/router/index.js:284:15
Top line first: the message. “Cannot read properties of undefined (reading ‘email’)” means some variable held undefined and the code asked it for .email anyway. In real life that is almost always something like user.email where user never got a value.
Every line starting with at is a stack frame: a function that was mid-call when everything stopped. The top frame is where the error was thrown. Each line below it is the caller of the line above, all the way down to wherever the request entered the program.
Now the rule that makes traces readable: find the first line of your code. Scan from the top and skip every frame that lives in node_modules or starts with node:internal. Those are the library and the runtime doing their jobs; the bug is occasionally in there, but for your first few hundred traces it will not be. The first frame you actually own is /app/src/services/email.js:14:27: your file, line 14, column 27. That is the line you open in your editor.
Both reading directions earn their keep:
Bottom-up is the cause. Read from the last line toward the first and you get the story in the order it happened: the request came in through Express, got routed, createUser ran, it called sendWelcomeEmail, and it blew up at line 14. When you’re asking “how did the program get into this state,” read upward.
Top-down is the effect. Start at the explosion and walk down, and you see everything that got interrupted: the email never sent, createUser never finished, the route handler never returned a response, which is why someone watched a spinner forever. When you’re asking “what did this failure break,” read downward.
In practice the most common move is a single hop, because the line that threw is often innocent. Line 14 of email.js says user.email, which is perfectly fine code; the real question is who called it with undefined. One frame down: users.js, line 31. Most beginner traces resolve exactly there, in the caller that passed garbage, one hop from the crash site.
Log levels are a volume knob
Most logging libraries (pino and winston are the usual Node picks) sort every message into a level. Four of them matter, quiet to loud:
debug: chatter that helps while building. “cache miss for user 42.”info: normal milestones. “server listening on 3000.” “user 42 created.”warn: survivable but off. “payment retry 2 of 3.” “MAIL_KEY unset, emails disabled.”error: something failed and a person should eventually look at it.
The point of levels is the knob. You set a minimum level, usually through a LOG_LEVEL environment variable, and everything quieter than the minimum is muted without deleting a single line of code. Development runs at debug and tells you everything. Production runs at info and stays readable. When you’re chasing something specific, you turn production up to debug for an hour and turn it back down after.
Which brings us to the failure mode you have already lived, even if you have never deployed: console.log everything. Every message at the same volume, hundreds per minute, “got here” and “GOT HERE 2” and the full response object from that one API call. The log technically contains the answer, and you will never find it, because the one error that matters is identical in shape and loudness to four thousand lines of routine chatter. When everything is loud, the log is silent.
To be clear, console.log itself is fine when you’re starting out. Hosts capture it as the runtime stream, and console.error even goes to the separate error stream (stderr). The upgrade that matters is the levels; a logger library is mostly a nicer way of getting them.
What your own code should log
A log you can read at 2 a.m. gets written at 2 p.m. Three categories are worth the keystrokes.
Inputs at boundaries. Log where data enters your system and where it leaves. An incoming request: method, path, who is asking. An outgoing call to Stripe or an email API: which order, which user. Boundaries are where bad data sneaks in, and a boundary log tells you whether the garbage arrived from outside or was manufactured in-house.
Decisions. When code picks a branch that matters, write down which one and why. “coupon SUMMER25 rejected: expired 2026-05-01” turns next month’s “the discount didn’t work” support mystery into a ten-second lookup.
Failures, with context. console.error("createUser failed", err) tells future-you that something broke for someone at some point. Attach the ids needed to retrace it: user id, order id, the request id if your host provides one. The stack trace says where; the ids say for whom, and “for whom” is what lets you reproduce it.
The matching restraint: log ids, not whole objects. Dumping the full user object feels thorough, and it is also how a password hash, a reset token, and a home address end up in a log file. userId: 42 is enough; you can look the rest up in the database, where it belongs.
The thirty-second triage
Something is wrong. Before reading a single line, ask one question: did the deploy itself fail, or did it succeed and the app is misbehaving? The answer picks the stream.
Deploy failed, site never updated: read the build log, bottom to top. Build tools print the real error and then bury it under generic exit noise, so the tail of a failed build log usually looks like this:
src/lib/api.ts:42:18 - error TS2339: Property 'token' does not exist on type 'Session'.
Found 1 error in src/lib/api.ts:42
Error: Command "npm run build" exited with 1
The last line says the build failed, which you already knew. Skip upward past the fallout to the first real error: the TS2339 with a file and a line number. That one is the cause; everything after it is consequence.
Deploy succeeded, app broken: read the runtime log around the timestamp. Get the time as precisely as anyone can give it (“checkout broke around nine”) and jump straight to that window instead of scrolling from the top. You are looking for the first error or warn entry in the window, ideally with a stack trace under it. One pattern worth checking immediately: if the errors begin at the exact minute a deploy went out, the deploy did it, and the fastest fix is rolling back first and reading the diff second.
Put it to work on your next deploy
The next time you ship something real, do one deliberate thing: keep the runtime log open in a tab while you click through the live site yourself. You will watch your own requests land in the record, you will probably catch a warning nobody would ever have reported, and the log stops being the scary tab. That familiarity is the actual skill; everything in this guide runs faster the second time.
And when the first real bug report arrives (“signup is broken, btw”), the move is mechanical instead of frightening. Deploy failed or app broken, so build log or runtime log. Find the window. Find the trace. First line of your code, then one hop down to the caller. That sequence is most of what “debugging in production” means for a first app.
The one thing logs will never do is speak up on their own. Nothing in that runtime stream taps you on the shoulder when errors start piling up at 3 a.m.; for that you need monitoring and alerts, which is exactly where this series goes next.
signal check
Can you answer these without scrolling back up?
-
Your deploy failed and the site never updated. Which log do you open, and where do you start reading?
The build log. Start at the bottom, skip the generic exited-with-1 noise, and find the first real error above it. Everything below that line is fallout, not cause.
-
A stack trace is forty lines long. How do you find the line to open in your editor?
Read the message on the first line so you know what kind of thing went wrong, then scan down the at-lines for the first frame that points at a file you wrote, skipping node_modules and node internals. That file, line, and column is where you start.
-
Why does logging everything at the same level make a log useless?
Because there is no way to filter it. Levels are a volume knob: in production you mute debug chatter so warn and error stay audible, and the one line that matters stops being identical to thousands of lines of routine chatter.
progress is stored in this browser only