Isometric illustration of log sheets flowing from a server through a pipeline into an archive cabinet

Rails log management in production comes down to four things: write one structured line per request to STDOUT, tag every line with a request ID, keep secrets and personal data out, and ship the logs somewhere that keeps them for longer than your platform’s buffer. Rails’ default multi-line logs are fine on a laptop and painful in production, where five concurrent requests interleave and a week of history means 1,500 lines on Heroku. This How-To sets up JSON request logs with Lograge (or Rails 8.1’s Rails.event), consistent tags across web and jobs, log levels and filtering, Docker log rotation, and log shipping on Heroku, Render, Fly.io and Kamal.


TL;DR

  • Log to STDOUT in production (the Rails 7.1+ default) and let the platform collect it. Never write to log/production.log inside a container.
  • Replace Rails’ multi-line request logs with one JSON line per request using Lograge, and add request_id and user_id to every line.
  • Rails 8.1 adds Rails.event for structured application events. Use it for business events like order.paid alongside your request logs.
  • Keep filter_parameters up to date and never log full request bodies. Logs are a common place for passwords, tokens and personal data to leak.
  • Platform log history is short (Heroku: 1,500 lines; Render: 7–30 days depending on the workspace plan), so add a drain or log stream to a log platform from day one, and cap Docker’s json-file logs on your own servers.

Table of Contents

Where do Rails logs go in production?

Since Rails 7.1, new apps log to STDOUT in production. The platform or container runtime collects that stream, keeps a short buffer, and forwards it to a drain if you configure one. Your app should never manage log files itself.

Where Rails logs go in production: STDOUT, platform collector, drain, log platform

That’s the twelve-factor model, and every platform in this guide expects it:

  • Rails writes events to STDOUT, one line per event, ideally JSON.
  • The platform collector (Heroku’s Logplex, Render’s log service, Fly.io’s NATS log stream, Docker’s logging driver) captures the stream and keeps a short history.
  • A drain or shipper forwards logs to a platform where you can search, alert and keep them for as long as you need.

The weak spot is the middle: built-in history is short and easy to lose. Heroku keeps 1,500 lines. On a busy app that’s a couple of minutes.

Prerequisites

  • Rails 7.1+ (examples use Rails 8.0 and 8.1, Ruby 3.3).
  • The lograge gem for JSON request logs.
  • An account with a log platform that accepts syslog or HTTPS (Better Stack, Papertrail, Grafana Loki, Datadog, Axiom, or self-hosted Loki/OpenSearch).
bundle add lograge

Step 1: Check the production logger configuration

Rails 8 generates a production logger that writes tagged lines to STDOUT. Make sure your production.rb still looks like this, especially if the app was upgraded from an older version:

# config/environments/production.rb
Rails.application.configure do
  # Log to STDOUT with the request ID as a tag on every line.
  config.log_tags = [:request_id]
  config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)

  # "info" in production. Use "debug" only temporarily, it logs every SQL query.
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

  # Health checks every second would flood the logs.
  config.silence_healthcheck_path = "/up"

  # Don't log deprecation warnings in production.
  config.active_support.report_deprecations = false
end

silence_healthcheck_path keeps the once-per-second /up checks described in the Rails health check guide out of your logs.

Upgraded apps often still have the Rails 6 pattern that writes to a file unless RAILS_LOG_TO_STDOUT is set. Remove it. In a container, a file inside the image is lost on every deploy and fills the container’s disk in the meantime.

Log levels in practice:

Level Use in production What you get
debug Only while debugging, for minutes Every SQL query, cache read and template render
info Default One request summary per request, job start/finish, your own info lines
warn Quieter apps with good error tracking Only problems and deprecations
error Rarely You’ll miss context during incidents

Pro Tip: Change the level without a redeploy by reading RAILS_LOG_LEVEL, as above. Then switching to debug for ten minutes during an incident is a config change, not a code change.

Step 2: One JSON line per request with Lograge

Rails’ default request logging writes four or more lines per request (“Started”, “Processing”, “Rendered”, “Completed”). With concurrent requests they interleave, and log platforms can’t parse them. Lograge collapses them into one line, and the JSON formatter makes every field searchable.

# config/initializers/lograge.rb
Rails.application.configure do
  next unless Rails.env.production?

  config.lograge.enabled = true
  config.lograge.formatter = Lograge::Formatters::Json.new

  # Don't log health checks or asset requests.
  config.lograge.ignore_actions = ["Rails::HealthController#show"]

  # Fields from the controller (runs inside the request).
  config.lograge.custom_payload do |controller|
    {
      request_id: controller.request.request_id,
      user_id: controller.respond_to?(:current_user, true) ? controller.send(:current_user)&.id : nil,
      ip: controller.request.remote_ip
    }
  end

  # Fields from the instrumentation event.
  config.lograge.custom_options = lambda do |event|
    {
      time: event.time.utc.iso8601(3),
      params: event.payload[:params]&.except("controller", "action", "format", "authenticity_token"),
      exception: event.payload[:exception]&.first,
      exception_message: event.payload[:exception]&.last
    }.compact
  end
end

A request now produces one line:

{"method":"GET","path":"/orders","format":"html","controller":"OrdersController","action":"index","status":200,"allocations":21483,"duration":48.21,"view":31.02,"db":11.34,"request_id":"9f2c1e7a-4b1d-4c55-9f6e-2a0b8c1d3e4f","user_id":42,"ip":"203.0.113.7","time":"2026-10-18T09:12:44.512Z","params":{"page":"2"}}

The params in the event are already filtered by filter_parameters (Step 4), so passwords appear as [FILTERED].

Background jobs. Active Job logs Performing/Performed lines with the job class and ID, tagged by the same logger. (Where those workers run is covered in Rails background jobs in production.) To connect a job back to the request that enqueued it, pass the request ID along:

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  attr_accessor :request_id

  # Capture the request ID when the job is enqueued...
  before_enqueue { self.request_id ||= Current.request_id }

  # ...store it in the job payload...
  def serialize
    super.merge("request_id" => request_id)
  end

  def deserialize(job_data)
    super
    self.request_id = job_data["request_id"]
  end

  # ...and tag every log line the job writes.
  around_perform do |job, block|
    Rails.logger.tagged("job:#{job.class.name}", "req:#{job.request_id || 'none'}") { block.call }
  end
end
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :request_id
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action { Current.request_id = request.request_id }
end

If that feels like too much plumbing, OpenTelemetry tracing propagates the same context automatically. The Rails monitoring guide shows how to correlate request IDs, logs and traces.

Step 3: Structured application events with Rails.event

Rails 8.1 adds Rails.event, a structured event reporter for application events. Use it for things you want to count and search later, like signups, payments and failed webhooks, instead of free-text Rails.logger.info lines.

# app/controllers/checkouts_controller.rb (Rails 8.1+)
def create
  order = Checkout.new(current_user, cart).call
  Rails.event.notify("order.paid", order_id: order.id, amount_cents: order.total_cents, currency: order.currency)
  redirect_to order
end

Add shared context once per request, so every event carries it:

# app/controllers/application_controller.rb (Rails 8.1+)
class ApplicationController < ActionController::Base
  before_action do
    Rails.event.set_context(request_id: request.request_id, user_id: current_user&.id)
  end
end

Events go to subscribers you register. A minimal one that writes JSON lines to the same log stream:

# config/initializers/event_log_subscriber.rb (Rails 8.1+)
class JsonEventLogSubscriber
  def emit(event)
    Rails.logger.info(event.slice(:name, :payload, :context, :tags, :timestamp).merge(type: "event").to_json)
  end
end

Rails.event.subscribe(JsonEventLogSubscriber.new) if Rails.env.production?

The benefit over ad-hoc log lines is consistency: a fixed name, a payload hash and shared context. Your log platform can then answer “how many order.paid events per hour” without regular expressions.

Step 4: Keep secrets and personal data out of logs

Logs are copied to more places and kept by more vendors than your database, so treat them as a place where secrets leak. Rails filters parameters by name. Make sure the list covers your app.

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc,
  # app-specific additions:
  :iban, :phone, :address, :api_key, :authorization
]

The matching is partial and case-insensitive, so :token also filters access_token, reset_password_token and api_token. Then check the places where filtering doesn’t apply:

  • Your own log lines. Rails.logger.info("Charging #{user.email} with #{card.inspect}") isn’t filtered. Log IDs, not objects.
  • Exception messages. Some gems include request bodies or headers in exception messages. Check what your error tracker and exception_message field actually capture.
  • Headers. Lograge doesn’t log headers by default. Don’t add request.headers to the payload.
  • SQL at debug level. Bound values are shown in debug logs. Another reason to keep production at info.

In the EU, IP addresses and user IDs that can be linked to a person are personal data under GDPR. Decide on a retention period (30 days is common for application logs), configure it in your log platform, and document it.

Step 5: Ship logs and set retention

Ship logs out of the platform buffer to a service that keeps them for your retention period. Every platform supports this with a single setting or command.

Heroku (Cedar):

heroku drains:add "syslog+tls://logs.example-provider.com:6514" -a myapp
heroku drains -a myapp

Render: add a log stream under Workspace Settings → Log Streams with your provider’s syslog endpoint and token. It applies to every service in the workspace.

Fly.io: run the log shipper as its own app. It reads your organisation’s log stream over NATS and forwards it with Vector:

fly launch --image ghcr.io/superfly/fly-log-shipper:latest --name myorg-log-shipper --no-deploy
fly secrets set ORG=personal ACCESS_TOKEN="$(fly tokens create readonly personal)" \
  BETTER_STACK_SOURCE_TOKEN=xxxxx -a myorg-log-shipper
fly deploy -a myorg-log-shipper

The shipper’s README lists the environment variables for each supported provider.

Kamal / Docker: Docker’s default json-file driver doesn’t rotate logs, so a chatty app eventually fills the disk. Cap it in config/deploy.yml, and ship with a logging driver or a Vector accessory:

# config/deploy.yml
logging:
  driver: json-file
  options:
    max-size: 100m
    max-file: "5"
# config/deploy.yml (accessory that ships container logs with Vector)
accessories:
  vector:
    image: timberio/vector:latest-debian   # pin a specific version in real deployments
    host: 10.0.0.11
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    files:
      - config/vector.yaml:/etc/vector/vector.yaml
# config/vector.yaml
sources:
  docker:
    type: docker_logs
    include_labels:
      - "service=myapp"
transforms:
  parse:
    type: remap
    inputs: [docker]
    source: |
      parsed, err = parse_json(.message)
      if err == null { . = merge!(., parsed) }
sinks:
  logs:
    type: http
    inputs: [parse]
    uri: https://in.logs.example-provider.com
    encoding:
      codec: json
    auth:
      strategy: bearer
      token: "${LOG_TOKEN}"

Kamal labels app containers with service=<service name>, which is what the include_labels filter uses.

Step 6: Verify it works

Confirm that requests produce one JSON line with a request ID, that secrets are filtered, and that lines reach your log platform.

# Locally, in production mode
RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails server &
curl -s -o /dev/null -H "X-Request-Id: test-123" "http://localhost:3000/orders?page=2"

The server output should contain a single line with "request_id":"test-123" and "path":"/orders". Then check the filter:

curl -s -o /dev/null -X POST -d "user[email]=a@example.com&user[password]=hunter2" http://localhost:3000/session
# log line contains "params":{"user":{"email":"[FILTERED]","password":"[FILTERED]"}}

Make sure the JSON parses:

kamal app logs --since 5m | grep '"request_id"' | sed 's/^[^{]*//' | head -5 | jq -c '{path, status, duration, request_id}'

Finally, search your log platform for the same request_id. If it shows up within a minute, shipping works. Then trigger a background job from that request and confirm the job’s lines carry the same req: tag.

How Heroku, Render, Fly.io, Upsun and Enkihost handle logs

Heroku, Render, Fly.io and Upsun collect STDOUT and offer a way to forward logs. Enkihost doesn’t document log retention or forwarding. The big differences are how much history you get without a drain, and which forwarding protocols they support.

Platform Built-in history Forwarding Limits worth knowing
Heroku (Cedar) 1,500 lines, up to 1 week Log drains (syslog, HTTPS) Lines over 10,000 bytes are split; L10 errors when a drain can’t keep up
Heroku (Fir) Real-time tail only Telemetry drains (OpenTelemetry/OTLP) No history without a drain
Render 7 days (Hobby), 14 days (Pro), 30 days (Scale/Enterprise) Log streams (syslog) 6,000 log lines per minute per instance; excess lines dropped
Fly.io Live tail via fly logs and the dashboard fly-log-shipper (NATS → Vector) Shipper is an app you run and pay for
Upsun Log files on the container, rotated and size-capped Log forwarding (syslog, HTTP, several vendors) Read with upsun log app
Kamal / Docker Whatever json-file keeps on disk Docker logging drivers, Vector No rotation unless you set max-size
Enkihost Not documented Not documented Log to STDOUT and ship from the app if you need retention

Render’s 6,000-lines-per-minute cap is a good reason to use Lograge even if you never look at the logs. Four lines per request means you hit the cap at 25 requests per second per instance, and the excess is dropped. With one line per request, you can serve four times more traffic before losing any log lines.

Enkihost runs each app with per-app resource isolation, and because the app logs to STDOUT, the Rails configuration in this guide doesn’t change. Shipping to a log platform follows the same pattern as above.

Troubleshooting Rails logging

No logs at all in production

The app is still writing to log/production.log. Look for ActiveSupport::Logger.new("log/production.log") or a RAILS_LOG_TO_STDOUT condition in production.rb, and replace it with the Step 1 configuration.

Errno::ENOSPC: No space left on device

Errno::ENOSPC: No space left on device @ io_write - /rails/log/production.log

A log file or Docker’s unrotated json-file logs filled the disk. Switch to STDOUT, set max-size/max-file for Docker, and clean up with docker system prune or by truncating the file the container log points to (docker inspect --format '{{.LogPath}}' <container>).

Error L10 (output buffer overflow) on Heroku

heroku[logplex]: Error L10 (output buffer overflow): 512 messages dropped since 2026-10-18T09:00:01+00:00.

Your app produces logs faster than a drain accepts them. Reduce volume (Lograge, info level, silence noisy endpoints), or switch to a drain endpoint that can handle the rate.

Log lines are cut or split

Heroku splits lines longer than 10,000 bytes, and other collectors have similar limits. That usually means you’re logging whole params hashes or large objects. Trim the payload in custom_options, and never log request bodies.

Puma or Solid Queue output isn’t JSON

Lograge only formats Rails request logs. Puma’s boot messages and some gem output go through their own loggers. That’s usually fine, since they’re rare lines. For Solid Queue, set config.solid_queue.logger = Rails.logger so its lines get the same tags.

Author Perspective: logs are for the incident you haven’t had yet

Logging is the thing I most often find under-configured when I join a project, and you only notice it at the worst moment: something broke two hours ago, and Heroku’s buffer has already rotated past it. The fix takes an afternoon. Lograge with JSON, a request ID on every line, a drain to anything searchable, and a 30-day retention policy. I also keep one rule about my own log lines: log identifiers and decisions, never objects. charge.failed order_id=812 reason=card_declined is useful at 3 AM. #<Order id: 812, …> with an email address inside is a liability.

Rails logs on Enkihost

Good logging only depends on the platform for two things: collecting STDOUT reliably, and not mixing your app’s resources with anyone else’s. On Enkihost:

Enkihost

  • STDOUT logging works as Rails generates it, so the production.rb, Lograge and Rails.event setup above applies unchanged.
  • Per-app resource isolation: a burst of logs or a debug-level session in one app doesn’t affect another app’s performance.
  • Zero-downtime deploys: request IDs and the release version in each line make it easy to see where one release stops logging and the next starts.
  • PostgreSQL and Redis add-ons inject DATABASE_URL and REDIS_URL, so connection details never need to appear in logs or code.

Rails and Sinatra apps run on Ignite (9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included) or Blaze (29 EUR per month, with high availability and autoscaling). The free Spark plan is for Jekyll sites. See enkihost.com.

FAQ

Where are Rails logs stored in production?

In Rails 7.1 and later, production logs go to STDOUT by default and are collected by the platform or container runtime rather than stored by Rails. To keep them, forward them to a log platform with a drain, a log stream or a shipper such as Vector. Older apps may still write to log/production.log.

Should I use Lograge in Rails 8?

Yes, for most production apps. Rails 8 still writes several lines per request by default, which interleave under concurrency and are hard to search. Lograge reduces each request to one line, and its JSON formatter makes every field searchable. Rails 8.1’s Rails.event complements it for application events.

How long should I keep Rails application logs?

Thirty days is a common default for application logs, long enough to investigate incidents and short enough to limit the personal data you hold. Keep audit or security logs longer if regulations require it, and keep them separately from application logs.

How do I stop passwords from appearing in Rails logs?

Add the parameter names to config.filter_parameters, which filters matching keys in request params everywhere Rails logs them. The match is partial, so token also covers access_token. Parameter filtering does not cover your own log messages, exception messages or headers, so log IDs rather than objects.

What is the difference between logs, metrics and traces?

Logs are individual events with details, metrics are numbers aggregated over time, such as requests per second or p95 latency, and traces follow one request across services and steps. Logs answer what happened to a specific request; metrics and traces show trends and where time is spent. A production Rails app benefits from all three, connected by a request ID.

Sources