At 2 a.m. your phone lights up: a customer DM that says "hey, checkout keeps failing for us." No error code, no screenshot, just that. You open your own product, click through the purchase flow, and watch a spinner hang until it dies. There is no on-call rotation to escalate to. There is you, a laptop, and your logs. Used well, those logs are the fastest path from symptom to cause you have — if you know how to read them.
Metrics and alerts tell you that something is wrong. Logs usually tell you why. The difference between staring at a status page in the dark and sending customers a real update within the hour is mostly one skill: reading production logs for troubleshooting. Anchor on a timestamp, find one failing request, follow it through your system, and let the error message tell its story. Founders who can do this resolve incidents in minutes. Founders who can't tend to do the two most expensive things available at 2 a.m.: restart blindly, or start rewriting code at 2:15.
This guide is the method. It covers what a log line is actually made of, where your logs live, a reading order that works under pressure, the patterns that explain most small-SaaS outages, the handful of commands you will really use, what to capture before you restart anything, and how to choose between rollback, restart, and fix-forward. It ends with a runbook you can print tonight. None of it requires a platform team.
Log Fundamentals: What a Log Line Actually Tells You
Nearly every useful log line has four parts. Learn to see them and every log stops being a wall of text.
- Timestamp — when it happened, ideally in UTC and with millisecond precision. This is what you will grep against first.
- Level — severity: DEBUG, INFO, WARN, ERROR, FATAL. Levels let you filter the interesting lines from the routine ones without reading everything.
- Context — the small identifiers attached to the event: request ID, user ID, route, job name. This is what lets you connect one line to the next.
- Message — what happened, in words a stranger could understand at a glance.
Logs come in two formats. Unstructured logs are free text: Checkout failed for user. Human-readable, but every line has a different shape, so you cannot reliably grep or parse them. Structured logs are key-value pairs, usually JSON, so every line has the same fields and machines can filter them. You can run a fine SaaS on plain-text logs, but the moment an incident starts, structured lines pay for themselves — you can pull every line for one request ID with a single grep.
One warning before we go further: console.log spam is not logging. Lines like here, step 2 done, and a dumped object with no timestamp, no level, and no request context are worse than nothing, because they bury the real signal. If your application emits thousands of identical-looking lines, the one ERROR that matters at 2 a.m. is effectively invisible. Logging is a signal system, not a diary. If every line looks the same, nothing stands out.
Where Your Logs Live
Before an incident, know where each of these four lives. Discovery at 2 a.m. is a bad use of the hour.
- Application logs. Everything your code writes to stdout and stderr, captured from your running container or process. Your stack traces live here. This is your first stop for any error inside your application.
- Web server or proxy logs. If a reverse proxy or load balancer sits in front of your app, its access log records every request with a status code, latency, path, and client IP. This is the honest record for questions like "did 500s really start at 02:08?" and "is this error coming from my app or from the edge?"
- Database logs. PostgreSQL and MySQL write their own logs: slow queries, refused connections, and — importantly for this article — errors like "too many connections." When your app says it cannot reach the database, the database's side of the conversation is here.
- System logs. If you run your own virtual machine, the operating system keeps records your app cannot: service restarts, crash loops, and out-of-memory kills. On Linux, journalctl is the door to all of it.
A proxy log line looks like this — note the status code and the request duration at the end:
203.0.113.42 - - [14/Mar/2026:02:08:21 +0000] "POST /api/checkout HTTP/1.1" 504 567 "-" "Mozilla/5.0" 30.012
That single line answers two questions instantly: the request returned 504 (a gateway timeout, not your app's own 500), and it took just over 30 seconds, which smells like an upstream call hanging until something gave up.
How to Read Production Logs: A Troubleshooting Method
When you are tired and stressed, do not read logs front to back. Work this four-step order every time.
Step 1: Anchor on the timestamp of the first failure report. The customer said 9:58 p.m. their time; your logs are probably in UTC. Convert before you search, and search a window that starts a little earlier — say ten minutes — because clocks drift and customers report late.
Step 2: Work backwards from that moment to the first anomaly. Scan the window before the first report for the earliest line that is not normal: the first WARN or ERROR, the first latency jump, the first silence in an otherwise chatty log. The first anomaly almost always predates the first complaint — customers are slow reporters, and the actual failure usually starts quietly.
Step 3: Find the request ID. A request ID is a short identifier stamped onto every log line produced while your system handles one request. If your framework or middleware does not add one, add middleware that does — it is the single highest-leverage logging change you can make, and it takes an afternoon.
Step 4: Follow one failing request end-to-end. Grep that request ID across every log you have: the proxy line, your app's lines, the downstream call to your database or payment provider, the response. One complete story beats fifty fragments. It tells you where the request entered, what it touched, and exactly where it died.
Here is an annotated excerpt from a fictional checkout service (annotations added). It is the log from the incident this article keeps returning to:
2026-03-14T02:07:41.218Z INFO [checkout] req_id=req_9f3ab12c POST /api/checkout 201 84ms2026-03-14T02:07:58.902Z INFO [checkout] req_id=req_c41d77e0 POST /api/checkout 201 91ms2026-03-14T02:08:14.375Z WARN [db] req_id=req_e02b558a pool acquire slow: waited 1240ms (in_use=10, idle=0) ← first anomaly2026-03-14T02:08:21.980Z ERROR [checkout] req_id=req_f77a21b9 POST /api/checkout failed after 30102ms Error: connect ETIMEDOUT 10.20.4.17:5432 at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1634:16) ← library frame: symptom at Pool.acquire (/app/src/db/pool.js:42:19) ← your code: start reading here at createOrder (/app/src/checkout/createOrder.js:88:24) ← your code: the failing request2026-03-14T02:08:22.104Z ERROR [checkout] req_id=req_04be8821 POST /api/checkout failed after 30057ms Error: connect ETIMEDOUT 10.20.4.17:5432 at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1634:16) at Pool.acquire (/app/src/db/pool.js:42:19) at createOrder (/app/src/checkout/createOrder.js:88:24)2026-03-14T02:08:29.517Z ERROR [worker] req_id=req_11cc09a2 job invoice:send failed: pool exhausted
Read what it says. Healthy 201 responses until 02:08:14. The first anomaly is not an exception — it is a warning that the connection pool ran dry. Then a wall of timeouts to one database address. In ninety seconds of reading you have a working hypothesis: this is not the payment provider and not the frontend; the application cannot get database connections. That is something you can act on.
Patterns That Explain Most 2 A.M. Outages
You do not need to diagnose every failure from first principles. A handful of recurring patterns cover most outages a small SaaS will ever see. Learn these six and you can classify an incident in the time it takes to scroll a log.
Stack traces: read the top frame in your own code first. A stack trace lists the call path that produced an error, innermost frame first. Frames belonging to libraries and frameworks are the symptom; the first frame whose path matches your repository is the scene. Read from the top until you hit a file you wrote, then start reasoning there. Save the full trace before you fix anything — it is the evidence your future self will want.
Repeated timeout patterns. The same ETIMEDOUT or ECONNREFUSED repeating every few seconds is not an application bug; it is a dependency that is down or unreachable — a database, a payment API, an email service. Your app is the messenger. Point your energy at the dependency, not at the code that reported the failure.
OOM kills versus exceptions. Exceptions leave stack traces. Out-of-memory kills leave silence: the log stream stops mid-traffic, the container restarts, and if you check system logs you find an exit code of 137 or the word "Killed." If you are grepping for an error and find nothing where the log goes quiet, that absence is the clue. No stack trace exists because no exception was thrown — the operating system pulled the plug.
Connection-pool exhaustion. Lines like pool exhausted, acquire timeout, or the database's own too many connections mean the app's pool of database connections has leaked or is undersized. Leaks come from code paths that open a connection and forget to release it. A restart clears the pool and buys hours; the leak will bring it back until you fix the code path.
5xx spikes versus 4xx spikes. A 5xx spike means the server failed — usually your problem. A 4xx spike means requests are being rejected: 401s and 403s point at authentication misconfiguration or a rotated key that did not land everywhere, and 429s point at rate limiting. The first actions for these are completely different, so check which one spiked before you touch anything.
- Pattern in the log: Stack trace with your file at the top — Likely cause: Bug in code you recently shipped — First action: Check the deploy diff; roll back if timing matches
- Pattern in the log: Identical ETIMEDOUT/ECONNREFUSED repeating — Likely cause: A dependency is down or unreachable — First action: Curl the dependency's health endpoint; check its status
- Pattern in the log: Log silence, container restarts, exit code 137 — Likely cause: Out-of-memory kill — First action: Confirm in system logs; restart, then resize or fix the leak
- Pattern in the log: "pool exhausted" / "too many connections" — Likely cause: Leaked connections or undersized pool — First action: Restart to clear; then find the leak; check DB max_connections
- Pattern in the log: 5xx spike starting at one clean timestamp — Likely cause: Bad deploy or dependency failure — First action: Correlate spike start with deploy time; roll back if aligned
- Pattern in the log: 4xx spike (401/403) — Likely cause: Auth config or key change broke requests — First action: Check recent secret rotation and auth provider settings
- Pattern in the log: 429s climbing — Likely cause: Rate limit hit by an aggressive client — First action: Identify the client; raise limits or block the offender
Commands Founders Actually Use
You need fewer commands than you think. These assume flat log files and obviously fictional paths — adapt the paths and service names to your setup:
# Follow the application log live while you reproduce the failuretail -f /var/log/invozy/app.log # Everything in the ten-minute window around the first failure reportgrep "2026-03-14T02:0" /var/log/invozy/app.log | less # Errors with surrounding context, not just the matching linegrep -B 3 -A 20 "ERROR" /var/log/invozy/app.log | less # One request's full journey across every service loggrep "req_f77a21b9" /var/log/invozy/*.log # Count errors in the hour to see when the spike started and how fast it grewgrep "2026-03-14T02" /var/log/invozy/app.log | grep -c ERROR # System-level view if you run your own box: crashes, restarts, OOM killsjournalctl -u invozy-api --since "02:00" --until "02:40" # Is the app even reachable right now?curl -sS -o /dev/null -w "%{http_code}\n" https://api.example.com/healthz
Two notes. First, if you cannot SSH into boxes — the normal situation on a managed platform — the same method applies through your platform's log view; only the location changes. Second, the curl against a health endpoint is the fastest single question you can ask: it separates "the whole app is dead" from "one feature is broken" in two seconds.
Capture Before You Restart
Restarting is sometimes the right move, but it destroys evidence. In-memory state is gone, connection tables are cleared, and the log lines you meant to scroll back through may rotate away. Before you restart anything, capture in this order:
- The customer's report: exact words, screenshot, and the timestamp with timezone.
- A raw log excerpt from the first anomaly to now — copy the text, do not screenshot it, so it stays searchable.
- One failing request ID and its full end-to-end journey saved to a file.
- The full stack trace of the primary error, untouched.
- The currently running release: deploy ID or commit hash, and the deploy time.
- The deploy diff between the last known-good release and this one.
- Container and system state: restart count, exit code, and any out-of-memory notices.
- A one-line timeline in your incident note: first anomaly, first report, actions taken.
Now you may restart — knowing that whatever you find afterward, you can still explain what happened.
From Diagnosis to Action
Diagnosis ends in a decision. There are four moves, and your evidence picks the one.
Roll back when the symptom's onset aligns with a deploy. If the first anomaly appears minutes after a release, the release is the prime suspect, and returning to the last healthy release is the cheapest correct move. You can diagnose calmly in daylight and fix forward properly. A rollback is not an admission of failure; shipping a known-bad version for six more hours is.
Restart when the process is wedged or was killed — an OOM, a deadlock, a stuck event loop — and you have captured the evidence. A restart buys hours, not a fix. Say so in your incident note, because a restart that "resolved" a connection leak guarantees a repeat at a worse hour.
Fix forward when the cause is small and verifiable — a missing environment variable, a pool size that is clearly too low — and you can ship and verify the change with a health check and a real transaction. Fixing forward under pressure is only safe when the fix is smaller than the rollback would have been.
Communicate — and this runs in parallel with all of the above, not after it. A short honest note — what you know, what you are doing, when the next update comes — costs two minutes and protects the relationship more than any uptime number. Customers forgive outages. They do not forgive silence.
Write Logs Your Future Self Can Read
Everything above gets easier if your application logs were designed for this moment. Four habits, all cheap to adopt today:
- Use levels consistently. INFO for normal operation, WARN for degraded-but-working, ERROR for failed operations. If everything is ERROR, nothing is; if nothing is ERROR, you will not see the fire.
- Attach a request ID to every line. Middleware that stamps one identifier on each request's logs is the difference between a story and confetti.
- Never log secrets. No tokens, no passwords, no authorization headers, no full card details — not even "temporarily." Logs get copied into tickets, pasted into chats, and screenshotted. Log that a request was authenticated; never log the credential that did it.
- Be careful with personal data. Log usr_8842, not an email address. You keep debuggability and make the log file something you are not afraid to look at, export, or share with a platform support engineer.
Structured logging makes all four habits enforceable, because every line has the same fields:
{ "ts": "2026-03-14T02:08:21.980Z", "level": "error", "service": "checkout", "request_id": "req_f77a21b9", "user_id": "usr_8842", "route": "POST /api/checkout", "status": 500, "duration_ms": 30102, "error": "connect ETIMEDOUT 10.20.4.17:5432", "msg": "checkout failed: could not acquire database connection"}
That single line answers what happened, to whom, how long it took, and why — with no secrets and no personal data — and it took the same effort to write as the console.log it replaced.
Where a Platform Shortens the 2 A.M. Path
A lot of the pain in this article is access friction: SSH keys you cannot find, a box you barely touch, logs scattered across three machines. This is where a managed deployment platform earns its keep. Deployxa deploys Git repositories or local projects as containerized applications — Node.js, Python, Go, PHP, Rust, and .NET projects — as long-lived workloads, and the application logs from those containerized deployments are accessible from the platform dashboard. The path from "customer says checkout is down" to "reading the actual log line" becomes minutes instead of an infrastructure scavenger hunt.
Logs also pair with the platform's health checks and blue/green releases. A new version deploys into a standby slot, gets health-verified, and only then receives traffic; the prior healthy release stays warm for a short rollback window, and rollback during that window can be sub-second. At 2 a.m., that turns the "roll back" branch of your decision into a click made on evidence — you have the failed health check and the log lines to justify it, and the prior release is still warm.
Be honest with yourself about the limits. The dashboard shows you the logs; it does not read them for you. You still need request IDs, sane log levels, and the method in this article. Check the Deployxa docs for current log retention, search, and export capabilities before you need them, not during an incident. And no platform decides for you whether to roll back or fix forward, or writes the update to your customers. That stays yours.
The 2 A.M. Runbook (Print This)
The method above compresses into one page. Print it, or pin it to your team wiki, and fill in the blanks as you go — the blanks force you to record what you will otherwise forget.
2 A.M. RUNBOOK — [your SaaS]============================ 1. FIRST REPORT [ ] Time of first customer report (with timezone): ________ [ ] What customers see (paste exact error / screenshot) [ ] Converted to log clock (UTC): ________ 2. COMMUNICATE (start now, update as you learn) [ ] Status note posted: what we know / what we are doing / next update by: ________ 3. LOCATE [ ] Deploy in the last 2 hours? Y / N — deploy time: ________ [ ] First anomaly time in logs: ________ [ ] Request ID of one failing request: ________ [ ] Full end-to-end journey of that request saved 4. CLASSIFY [ ] Pattern (circle one): stack trace / timeout loop / OOM silence / pool exhaustion / 5xx spike / 4xx spike [ ] Likely cause: ________ 5. CAPTURE BEFORE ANY RESTART [ ] Log excerpt (first anomaly to now) saved [ ] Full stack trace saved [ ] Deploy diff (last good vs current) saved [ ] Exit code / restart count / OOM notices recorded 6. DECIDE AND ACT (choose one) [ ] ROLL BACK — symptom aligned with a deploy [ ] RESTART — wedged or OOM, evidence captured [ ] FIX FORWARD — small verified fix (env var, pool size) [ ] Verified: health check green + one real transaction through 7. CLOSE OUT [ ] Customer follow-up sent [ ] Incident note scheduled (30 min, tomorrow): timeline, cause, fix, prevention
Here is your one next step, and it takes twenty minutes tonight: write your version of this runbook, then test one diagnostic command against a non-production deployment — deploy a throwaway project, break something on purpose, and grep for a request ID or curl a health endpoint. You cannot write a calm runbook at 2 a.m., but you can hand the 2 a.m. version of yourself a page that already knows where the logs live, what to capture, and which decision to make. That page is the difference between an incident and a bad night.