Stream logs to onlylogs.io
If self-hosting your logs is not what you want, onlylogs.io has you covered. There are many reasons why you might not want to self-host, for example ephemeral disks on Heroku or deploio, or you simply want your logs to live in a space separated from your application.
This page explains all the options you have to stream your logs to onlylogs.io.
Getting started in two minutes
Create a project on this website. Its page shows your ONLYLOGS_DRAIN_URL. Send it a line from your terminal:
curl --data-binary "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [I] hello from $(hostname)" $ONLYLOGS_DRAIN_URL
Open the project page: the line is there, live.
ok means stored. drain paused (403) means the project is paused. Project not found (404) means the URL is wrong. Everything else on this page is about getting your platform to do what curl just did.
Which method is for me?
| You run on | Use | Lines arrive as |
|---|---|---|
| Heroku | Native drain | syslog-framed |
| Clever Cloud | Native drain | as sent by the platform |
| Dokku | Vector plugin | verbatim |
| Kamal | onlylogs gem or Vector accessory | verbatim |
| Fly.io | fly-log-shipper | JSON |
| deploio, Railway, Render, Scalingo, DigitalOcean | onlylogs gem or Vector sidecar | verbatim |
| Kubernetes | Vector DaemonSet | verbatim |
| Docker Compose, Coolify, Dokploy | Vector container | verbatim |
| A plain VPS with systemd | Vector journald source | verbatim |
| Any Rails app | onlylogs gem | verbatim |
| Node, Python, PHP, Go, ... | Direct HTTP or Vector | verbatim |
How the drain endpoint behaves
Whatever ships your logs, the endpoint is the same:
POSTa plain-text body to yourONLYLOGS_DRAIN_URL. It is appended verbatim to the project'sdrain.log, plus a trailing newline when the body has none. Nothing is parsed or reformatted.- One request can carry one line or thousands: separate them with newlines.
- Bodies compressed with
Content-Encoding: gzipare accepted. - No headers are required. The secret is the URL itself, so anything that can
POSTcan ship. 200means stored.403means the project is paused (you can pause and resume a project from its page; paused lines are discarded).404means the drain code is unknown.drain.logis rotated at 10 GB, or once it spans a tenth of the project's retention, into a file named after that moment (drain.log.20260925T023000Z). Rotations older than the retention are deleted every night. Every file can be downloaded or deleted from the project page.
PaaS log drains
Some platform-as-a-service providers offer a built-in way to forward stdout to an HTTPS endpoint. Point it at your ONLYLOGS_DRAIN_URL and you're done.
Heroku
Heroku has first-class log drain support:
heroku drains:add $ONLYLOGS_DRAIN_URL
Heroku sends each line wrapped in its syslog framing (<40>1 2026-04-17T12:34:56+00:00 host app web.1 - ...). onlylogs.io stores lines exactly as they arrive, so expect that prefix in front of every line. Search works regardless: the prefix is just more text. If you would rather not see it, the onlylogs gem ships from inside the dyno and bypasses the drain entirely.
Clever Cloud
Clever Cloud has a generic HTTP drain type:
clever drain create raw-http $ONLYLOGS_DRAIN_URL
Platforms without a drain
Not every platform forwards logs to an arbitrary HTTPS endpoint:
- deploio and Railway show logs in their own console and CLI only.
- Render, Scalingo and DigitalOcean App Platform forward to a fixed list of vendors, or to syslog over TLS, which onlylogs.io does not speak yet.
On all of them you have two options that work today:
- A Rails app uses the onlylogs gem. It ships from inside the process, so the platform's log pipeline is not involved at all.
- Any other app runs a Vector sidecar or a second process that tails the app's output and ships it with the Vector snippet below.
Vector
For platforms without native drains, deploy a log shipper such as Vector and ship to your ONLYLOGS_DRAIN_URL. This is the sink every recipe below builds on:
[sinks.onlylogs]
type = "http"
inputs = ["your_source"]
uri = "${ONLYLOGS_DRAIN_URL}"
method = "post"
encoding.codec = "text"
framing.method = "newline_delimited"
The text codec sends the raw log line, so nothing Docker- or Vector-specific ends up in front of your output.
Fluent Bit works too, with its http output and format json_lines. Each line then arrives as a JSON object rather than as plain text, which is why the recipes here use Vector.
Dokku
Dokku forwards logs via its built-in Vector plugin. Start it once, then configure the sink. Use the text codec so lines arrive verbatim rather than wrapped in Docker metadata. <URL_ENCODED_DRAIN_URL> is your drain URL passed through CGI.escape:
dokku logs:vector-start
dokku logs:set <app-name> vector-sink "http://?uri=<URL_ENCODED_DRAIN_URL>&encoding[codec]=text&method=post"
Add --global instead of an app name to ship every app on the host to the same project.
Kamal
Kamal runs your app in Docker containers and has no log drain of its own. The simplest route is the onlylogs gem: every process type (Puma, jobs, kamal app exec) ships its own lines, and kamal logs keeps working because the logger still writes to $stdout.
Reference the drain URL as a secret in config/deploy.yml:
env:
secret:
- ONLYLOGS_DRAIN_URL
Then resolve it in .kamal/secrets from your environment or password manager, for example:
ONLYLOGS_DRAIN_URL=$ONLYLOGS_DRAIN_URL
If you would rather not add a gem, run Vector as a Kamal accessory that tails the Docker logs of your app containers. Kamal labels every container with service=<your-service>, so Vector can pick them up by label:
# config/deploy.yml
accessories:
vector:
image: timberio/vector:0.49.0-alpine
host: <your-server-ip>
env:
secret:
- ONLYLOGS_DRAIN_URL
files:
- config/vector.yaml:/etc/vector/vector.yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# config/vector.yaml
sources:
app:
type: docker_logs
include_labels:
- service=<your-service>
sinks:
onlylogs:
type: http
inputs: [app]
uri: ${ONLYLOGS_DRAIN_URL}
method: post
encoding:
codec: text
framing:
method: newline_delimited
Boot it once with kamal accessory boot vector.
Fly.io
Fly.io publishes every app's logs on an internal NATS stream. The fly-log-shipper app subscribes to it and forwards to a sink of your choice. Its generic HTTP sink needs two secrets:
fly secrets set HTTP_URL=$ONLYLOGS_DRAIN_URL HTTP_TOKEN=unused --app <your-log-shipper>
onlylogs.io authenticates by URL and ignores the bearer header, so any value works for HTTP_TOKEN. This sink uses Vector's json codec: each line arrives as a JSON object with the text under message and the app, region and instance alongside. Search still finds your text inside it. If you want verbatim lines, fork the shipper and replace the sink with the text snippet above, or use the onlylogs gem for a Rails app.
Kubernetes
Run Vector as a DaemonSet with the kubernetes_logs source and the onlylogs sink. Narrow the source to your namespace or labels so cluster noise stays out of your project:
# vector.yaml, mounted into the DaemonSet
sources:
app:
type: kubernetes_logs
extra_label_selector: "app=<your-app>"
sinks:
onlylogs:
type: http
inputs: [app]
uri: ${ONLYLOGS_DRAIN_URL}
method: post
encoding:
codec: text
framing:
method: newline_delimited
The official Vector Helm chart installs the DaemonSet; pass the file above as customConfig and the drain URL as a secret-backed environment variable.
Docker Compose, Coolify, Dokploy
Anything that runs plain Docker containers, including self-hosted PaaS such as Coolify and Dokploy, can ship with one extra container that reads the Docker socket:
# docker-compose.yml
services:
app:
image: your/app
labels:
onlylogs: "true"
vector:
image: timberio/vector:0.49.0-alpine
environment:
ONLYLOGS_DRAIN_URL: ${ONLYLOGS_DRAIN_URL}
volumes:
- ./vector.yaml:/etc/vector/vector.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
# vector.yaml
sources:
app:
type: docker_logs
include_labels:
- onlylogs=true
sinks:
onlylogs:
type: http
inputs: [app]
uri: ${ONLYLOGS_DRAIN_URL}
method: post
encoding:
codec: text
framing:
method: newline_delimited
Label every container you want shipped and leave the rest alone.
A plain VPS
On a server where your app runs under systemd, Vector's journald source picks up everything the unit writes to stdout:
# /etc/vector/vector.yaml
sources:
app:
type: journald
include_units: [your-app.service]
sinks:
onlylogs:
type: http
inputs: [app]
uri: ${ONLYLOGS_DRAIN_URL}
method: post
encoding:
codec: text
framing:
method: newline_delimited
For files instead of the journal, such as nginx or Caddy access logs, swap the source:
sources:
nginx:
type: file
include: [/var/log/nginx/access.log]
Ruby on Rails
If your platform doesn't expose a log drain — or you want log shipping from every process type (Puma, GoodJob, Sidekiq, rake, migrations) — use the onlylogs gem, version 0.10.0 or later. It includes two loggers: HttpLogger (recommended) and SocketLogger (for Puma-only deployments).
Both loggers write every line to $stdout as well, so your platform's own log viewer keeps working while lines are streamed to onlylogs.io.
HttpLogger (recommended)
The HttpLogger ships logs directly via HTTP from any Ruby process: Puma (including cluster mode), GoodJob, Sidekiq, rake tasks, migrations. No sidecar or Puma plugin required.
# config/environments/production.rb
config.logger = Onlylogs::HttpLogger.new
Set the ONLYLOGS_DRAIN_URL environment variable and you're done. Without it, the logger logs locally only and prints a warning at boot.
Optional environment variables:
| Variable | Default | Description |
|---|---|---|
ONLYLOGS_DRAIN_URL |
— | The drain URL (required) |
ONLYLOGS_BATCH_SIZE |
100 |
Number of lines to batch before sending |
ONLYLOGS_FLUSH_INTERVAL |
0.5 |
Flush interval in seconds |
ONLYLOGS_MAX_QUEUE_SIZE |
10000 |
Lines held in memory before new ones are dropped |
ONLYLOGS_MAX_BATCH_BYTES |
1048576 |
Upper bound of a request body; longer lines are truncated and marked |
ONLYLOGS_OPEN_TIMEOUT |
0.5 |
Connect timeout in seconds |
ONLYLOGS_READ_TIMEOUT |
0.5 |
Response timeout in seconds |
ONLYLOGS_CIRCUIT_COOLDOWN |
30 |
Seconds to stop sending after three consecutive failures |
ONLYLOGS_KEEP_ALIVE_TIMEOUT |
30 |
Seconds an idle connection is kept for reuse |
ONLYLOGS_SPOOL_DIR |
tmp/onlylogs/spool |
Where undelivered batches are buffered; set it empty to disable the spool |
ONLYLOGS_SPOOL_MAX_BYTES |
134217728 |
Size of the on-disk spool (128 MB); oldest batches are evicted first |
Or pass them as keyword arguments:
config.logger = Onlylogs::HttpLogger.new(
drain_url: "https://sink-1.onlylogs.io/drain/your-token",
batch_size: 50,
flush_interval: 1.0
)
Invalid values never prevent the app from booting: a non-numeric or non-positive number falls back to its default with a warning, and a drain URL that is not http(s) falls back to local-only logging.
When onlylogs.io is unreachable
The logger is built so that a slow or dead drain never stalls your app and an outage never loses logs:
- Sending happens on a background thread. Log calls only enqueue a line, and the queue is bounded, so memory cannot grow without limit.
- Timeouts are short (half a second by default). After three consecutive failures the circuit opens and no request is attempted for about the cooldown period.
- Batches that could not be delivered are written to the on-disk spool and replayed once the drain answers again, and on the next boot. Delivery is at-least-once: a batch whose response was lost is replayed and shows up twice.
- A
429is honoured: the logger waits forRetry-Afterand keeps the batch. Any other4xx(unknown token, paused project, body too big) is dropped with a warning, since retrying cannot help.
Dropping noisy lines
Lines you never want to store, such as health checks or asset requests, can be filtered out before they are sent. Anything matching one of the patterns is discarded:
# config/environments/production.rb
config.logger = Onlylogs::HttpLogger.new
config.logger.formatter.denylist = [/health_check/, /\/assets\//]
Since storage is what you pay for, this is the cheapest way to keep your bill down.
SocketLogger + Puma sidecar
If you prefer the sidecar approach (lower latency, useful when Puma is the only process that needs log shipping):
# config/environments/production.rb
config.logger = Onlylogs::SocketLogger.new
This writes logs to ONLYLOGS_SIDECAR_SOCKET (default: tmp/sockets/onlylogs-sidecar.sock). From there, stream them to onlylogs.io using a Puma sidecar process:
# config/puma.rb
plugin :onlylogs_sidecar
Then configure the ONLYLOGS_DRAIN_URL environment variable.
You can also run the sidecar process separately:
bundle exec onlylogs_sidecar
Note: the
SocketLoggeronly works when the sidecar process is running. Background jobs and migrations that run outside of Puma will not have access to the socket. If you need log shipping from all process types, use theHttpLoggerinstead.
Keeping local files while streaming
By default the local copy goes to $stdout, exactly what a Rails app does in production on its own. Keep it that way on an ephemeral filesystem (Heroku, Deploio, Docker, Kubernetes): a log file there is lost on every deploy and, while it grows, its page cache counts towards the container's memory limit, showing up as a memory leak that resets on each release.
If your app has a persistent disk and you want the files too, pass a rotating log device as local_fallback and keep a single logger:
# config/environments/production.rb
log_file = Logger::LogDevice.new(
Rails.root.join("log", "production.log"), shift_age: 5, shift_size: 100.megabytes
)
config.logger = Onlylogs::HttpLogger.new(local_fallback: log_file)
Every log line is written to the local file and forwarded to onlylogs.io. The LogDevice handles rotation (keep 5 files of 100 MB each in the example above). If the remote connection fails, logs continue to be written locally without interruption.
Warning — do not use
ActiveSupport::BroadcastLoggerwith two tagged loggers. This is a known Rails bug: rails/rails#56669.# DO NOT do this: local = Onlylogs::Logger.new("production.log", 5, 100.megabytes) remote = Onlylogs::HttpLogger.new config.logger = ActiveSupport::BroadcastLogger.new(local, remote)
Other languages
The endpoint is a plain-text POST, so any language can ship without a library. The simplest robust setup for a non-Ruby app is still to write to stdout and let your platform or Vector forward it: the shipper handles batching, retries and backpressure for you.
If you would rather ship from inside the process, batch lines and send them on a timer. Never send one request per log call from a request handler.
Node.js:
// onlylogs.js
const buffer = [];
export const log = (line) => buffer.push(line);
setInterval(async () => {
if (buffer.length === 0) return;
const body = buffer.splice(0).join("\n");
try {
await fetch(process.env.ONLYLOGS_DRAIN_URL, { method: "POST", body, signal: AbortSignal.timeout(500) });
} catch {
// dropped: never let logging take the app down
}
}, 500).unref();
Python:
# onlylogs.py
import logging, os, threading, urllib.request
class OnlylogsHandler(logging.Handler):
def __init__(self, interval=0.5):
super().__init__()
self.buffer, self.lock = [], threading.Lock()
threading.Timer(interval, self.flush).start()
def emit(self, record):
with self.lock:
self.buffer.append(self.format(record))
def flush(self):
with self.lock:
lines, self.buffer = self.buffer, []
if lines:
request = urllib.request.Request(os.environ["ONLYLOGS_DRAIN_URL"], data="\n".join(lines).encode(), method="POST")
try:
urllib.request.urlopen(request, timeout=0.5)
except OSError:
pass
threading.Timer(0.5, self.flush).start()
For PHP (Monolog), Go, Elixir and others the shape is the same: a handler that appends to a buffer and a timer that posts the buffer as newline-delimited text.
Recommended log format
You don't need our gem to stream logs to onlylogs.io: any log drain or shipper can forward a plain-text stream. The backend accepts arbitrary lines, but here is a good default:
[2026-04-17T12:34:56+00:00] [I] Your log message here
That is: an ISO 8601 timestamp in square brackets, the severity letter (D, I, W, E, F) in square brackets, then the message.
In a Rails app you can produce this format with a one-line formatter — no gem required:
# config/environments/production.rb
config.log_formatter = proc do |severity, time, _progname, msg|
"[#{time.iso8601}] [#{severity[0]}] #{msg}\n"
end
This pairs well with config.log_tags = [:request_id] for per-request correlation, and works the same whether you're logging to $stdout (Heroku, Dokku) or to a file that Vector tails.
Why timestamps matter
Log files grow to gigabytes, and the AI agent tools find a time range by bisecting a file on the timestamps of the lines they read. Lines without a timestamp, or with one the agent cannot compare, force it to scan more of the file than necessary. Use UTC and ISO 8601 and the agent lands on the right window in a handful of reads. The same goes for you: a consistent timestamp is what makes "everything between 14:02 and 14:05" a simple regex.
Multi-line entries
Every line is stored on its own. A stack trace is therefore thirty lines that happen to be adjacent, and a search for the exception class returns only the line that names it. Tag each line with the request id (config.log_tags = [:request_id] in Rails) and one search for that id returns the whole request, trace included.
Sensitive data
Logs are stored as they arrive, so whatever your app writes is what onlylogs.io keeps. Two filters, both on your side, keep secrets out:
- Rails'
config.filter_parametersmasks passwords, tokens and card numbers in request logs before they are written anywhere. - The gem's denylist drops whole lines that match a pattern, which also works for lines that are merely noisy.
Organising projects
A project is one stream with one URL, so use it as the unit of isolation:
- One project per environment.
myapp-productionandmyapp-stagingkeep a staging deploy from polluting your production search, and let you pause staging when nobody looks at it. - Several apps in one project is fine when they belong together, for example a web app and its worker. Prefix each line with the app name, or rely on the
[web.1]-style tag your platform adds, so a search can still tell them apart. - Teams own projects and pay for them. Every project belongs to exactly one team, and everyone in that team can read it. New projects land in your Personal team; move one into a shared team to give a group access and hand them the bill.
- Guests are for externals who need one project and nothing else: add them on the project's access page and they never see the team, its other projects or its bill.
Rotating a leaked drain URL
The drain URL is a bearer secret: anyone who has it can write into your project. Pausing the project stops writes immediately. To retire a URL for good, create a new project, switch your app to its URL, and delete the old project once its logs are no longer needed.
Importing existing logs
The endpoint appends whatever it receives, so it also works for a one-off upload of logs you already have:
gzip -c log/production.log | curl --data-binary @- -H "Content-Encoding: gzip" $ONLYLOGS_DRAIN_URL
Lines land at the end of drain.log in the order they were sent. Upload old files before switching on live shipping if you want the file to stay chronological.
Capacity
Each project keeps up to four files of 10 GB: the live drain.log and three rotations. Rotation is checked once a day, so a very busy stream can grow past 10 GB between checks. When the fourth rotation falls off the end, its lines are gone.
Download a rotation from the project page before that happens if you need it archived. If your app routinely produces more than the rotation window holds, the denylist is the first lever to pull: health checks and asset requests are usually most of the volume.
Troubleshooting
- Nothing arrives. Run the curl test with the same URL your app uses.
404means a typo in the drain code;403means the project is paused. If curl works and the app does not, the app cannot reachsink-1.onlylogs.io: check egress rules and, in the gem, the warning printed at boot. - Lines arrive late. Shippers batch: the gem flushes every half second or every 100 lines, Vector and Fluent Bit have their own intervals. A quiet app can therefore show a lag of a few seconds on its last lines.
- Lines appear twice. Delivery is at-least-once. A batch whose response was lost, for example during a deploy, is sent again. Search for the request id to tell the copies apart.
- A line is cut off and ends with a marker. The gem truncates single lines longer than
ONLYLOGS_MAX_BATCH_BYTES(1 MB by default). Huge lines are almost always a dumped object; log its id instead. - Every line starts with a syslog prefix. That is your platform's drain framing, see Heroku. Ship from inside the process with the gem to avoid it.
Search your logs from an AI agent
onlylogs.io is also an MCP server, so an agent can search your logs the way you would. The endpoint is https://onlylogs.io/mcp, protected by OAuth 2.1: the first connection opens a browser window where you sign in with your onlylogs.io account, and the agent then sees exactly the projects you can see.
With Claude Code:
claude mcp add --transport http onlylogs https://onlylogs.io/mcp
Any client that supports Streamable HTTP with OAuth (dynamic client registration and PKCE) works the same way.
Two tools are exposed: list_projects returns your projects and their files with sizes and last-modified times, and search_logs greps a byte window of one file, literally or with a regular expression. Log files can be several gigabytes, so the tool descriptions teach the agent to bisect a file on the timestamps of its lines instead of reading it end to end.