Skip to content

Self-hosting

The companion is a small server that sends your email, hosts unsubscribe pages and images, and records bounces. Running it on a server you control gives you everything Rowsend Cloud does. See This computer for the comparison.

You need a server with Docker, a public HTTPS address for it, and a few hundred MB of disk.

Save this as compose.yaml on your server:

services:
companion:
image: ghcr.io/rowsend/companion:latest
environment:
ROWSEND_API_KEY: ${ROWSEND_API_KEY:?Set ROWSEND_API_KEY}
ROWSEND_SIGNING_SECRET: ${ROWSEND_SIGNING_SECRET:-}
# Set this to send scheduled campaigns while your computer is off. See below.
ROWSEND_MASTER_KEYS: ${ROWSEND_MASTER_KEYS:-}
# For SMTP bounces, see "SMTP bounces" below: set this for Cloudflare's Email Worker,
# or uncomment BOUNCE_SMTP_PORT and the port below to receive them on port 25.
ROWSEND_INBOUND_SECRET: ${ROWSEND_INBOUND_SECRET:-}
# BOUNCE_SMTP_PORT: 2525
ports:
- "8787:8787"
# - "25:2525"
volumes:
- companion-data:/data
restart: unless-stopped
volumes:
companion-data:

Pick a long random API key, then start it:

Terminal window
ROWSEND_API_KEY="$(openssl rand -hex 32)" docker compose up -d

Keep the key; Rowsend needs it to connect. Once the companion is running, curl -i http://localhost:8787/up returns 204 No Content.

Recipients’ mail apps open the unsubscribe links and images, so the companion needs a public HTTPS address such as https://email.example.com. Run a reverse proxy in front of port 8787. With Caddy, which gets the certificate for you, the whole config is:

email.example.com {
reverse_proxy localhost:8787
}
  1. Open Settings → Companion and choose My server.
  2. Enter the Server URL (https://email.example.com) and the API key.
  3. Click Save and connect. The health card shows Running with the companion’s version.

Rowsend sends to the companion over that URL, and every email’s unsubscribe link and images point at it. To use a different hostname in links, set Unsubscribe link domain in Project settings → Sending and point it at the same server.

Variable Default What it does
ROWSEND_API_KEY none, required The key Rowsend connects with
ROWSEND_SIGNING_SECRET the API key Signs unsubscribe links and bounce addresses. Set it to keep links in sent mail working when you change the API key.
ROWSEND_MASTER_KEYS off Encrypts the credentials your server keeps for sending while your computer is off
S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_ENDPOINT, S3_REGION, S3_FORCE_PATH_STYLE off Keeps images in a bucket instead of on disk. See Backups.
ROWSEND_INBOUND_SECRET off Lets the Cloudflare Email Worker post SMTP bounces to the companion
BOUNCE_SMTP_PORT off Turns on the bounce receiver for SMTP on this port inside the container
PORT 8787 The port the companion listens on
HOST 0.0.0.0 The address it listens on
DATA_DIR /data Where the database and images live

Unsubscribe links in mail you’ve already sent are signed with the signing secret, which is the API key unless you set one. Before changing ROWSEND_API_KEY, set ROWSEND_SIGNING_SECRET to the old key, so those links keep working. Then update the key in Settings → Companion.

Normally your server only holds a project’s sending credentials while Rowsend is open, so scheduled campaigns go out only if your computer is awake at the time. To send them with your computer off, give the server a master key to encrypt stored credentials with:

Terminal window
ROWSEND_MASTER_KEYS="1:$(openssl rand -base64 32)"

Add it to the environment the companion starts with, and restart it. Keep a copy: without it, the server can’t read the credentials it stored. Then open Project settings → Sending in Rowsend and turn on Keep credentials on your server for each project that needs it.

What changes for that project:

  • Your server keeps its sending credentials, encrypted with the master key. It uses them only for that project’s scheduled campaigns and their bounces.
  • Recipients are fixed when you schedule. Rowsend works out who gets the campaign at that moment and sends your server the finished messages. Contacts synced or segments changed after that don’t affect it. To pick up changes, unschedule and schedule it again.
  • Results show up when you next open Rowsend.

Turning it off deletes the credentials from your server. So does switching Rowsend to another companion.

To change the master key, add the new one with a higher version and keep the old one (2:<new>,1:<old>), restart, run docker compose exec -u node companion node dist/admin.js credentials:rewrap, then remove the old key and restart again.

When you send over SMTP, each message goes out with its own signed return address on a bounce domain, such as bounces.example.com, and the companion records bounces that come back to it. See SMTP.

Bounce mail can reach the companion in two ways. Rowsend Cloud uses the first.

Cloudflare Email Routing receives the bounce mail and a small Worker posts it to your companion over HTTPS. Your server doesn’t need port 25, so this works on hosts that block it or have no firewall to open, like Fly.io. You need the bounce domain’s DNS on Cloudflare; a free plan is enough.

  1. Generate a secret with openssl rand -base64 32, set it as ROWSEND_INBOUND_SECRET for the companion, and restart it.

  2. Download the Worker, rowsend-bounces.js. It’s attached to every Rowsend release.

  3. Deploy it from the folder you saved it in. Use your companion’s address, and your bounce domain; for several projects with different bounce domains, list them separated by commas:

    Terminal window
    npx wrangler deploy rowsend-bounces.js --name rowsend-bounces --compatibility-date 2026-09-01 --var COMPANION_URL:https://email.example.com --var BOUNCE_DOMAIN:bounces.example.com
  4. Give it the same secret when asked:

    Terminal window
    npx wrangler secret put INBOUND_SECRET --name rowsend-bounces

    Without Node.js, you can create a Worker named rowsend-bounces in the Cloudflare dashboard instead, paste the file into its code editor, and add COMPANION_URL, BOUNCE_DOMAIN and the INBOUND_SECRET secret under its settings.

  5. In the Cloudflare dashboard, open Email → Email Routing for the domain. Under Settings, add the bounce domain as a subdomain and turn on Subaddressing. Under Routing rules, send b@bounces.example.com to the rowsend-bounces Worker. Bounce addresses look like b+<id>@bounces.example.com, so subaddressing sends them all through that one rule.

  6. In Project settings → Sending, enter it as the Bounce domain.

  1. Uncomment BOUNCE_SMTP_PORT and the 25:2525 port in compose.yaml, and open port 25 in your server’s firewall. Some cloud hosts block port 25 until you ask.
  2. Add an MX record pointing your bounce domain at your server.
  3. In Project settings → Sending, enter it as the Bounce domain.

Everything lives in the companion-data volume: companion.db and the images folder. Your contacts and campaigns aren’t there; they’re in Rowsend on your computer. Losing the volume loses unsubscribes recorded since Rowsend last pulled them, sending history and hosted images.

For continuous backups to S3 or Cloudflare R2, set these and the companion runs under Litestream. On an empty volume it restores from the backup first.

Variable What it is
LITESTREAM_BUCKET The bucket name
LITESTREAM_ENDPOINT For R2, https://<account id>.r2.cloudflarestorage.com. Leave unset for S3.
LITESTREAM_ACCESS_KEY_ID The bucket’s access key
LITESTREAM_SECRET_ACCESS_KEY Its secret

Litestream backs up the database, not the images folder. To keep images off the server too, store them in a bucket.

Set these and the companion stores images in any S3-compatible bucket instead of the images folder. They keep working if you lose the volume or move to another server, and on hosts that wipe the disk on every deploy. Recipients still load images through the companion’s address, so the bucket can stay private.

Variable What it is
S3_BUCKET The bucket name
S3_ACCESS_KEY_ID An access key that can read, write and delete in the bucket
S3_SECRET_ACCESS_KEY Its secret
S3_ENDPOINT Your provider’s S3 address: https://<account id>.r2.cloudflarestorage.com for R2, for example. Leave unset for AWS S3.
S3_REGION The bucket’s region. Required for AWS S3; defaults to auto, which suits R2.
S3_FORCE_PATH_STYLE true for MinIO and other stores that need the bucket name in the path

If only some of these are set, the companion refuses to start and names what’s missing.

Images uploaded before you set up the bucket stay on disk and keep loading. Only new uploads go to the bucket, so keep the volume until you no longer need the old images.

Terminal window
docker compose pull && docker compose up -d

Database migrations run when the companion starts.