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.
Run it
Section titled “Run it”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:
ROWSEND_API_KEY="$(openssl rand -hex 32)" docker compose up -dKeep the key; Rowsend needs it to connect. Once the companion is running, curl -i http://localhost:8787/up returns 204 No Content.
Put HTTPS in front
Section titled “Put HTTPS in front”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}Connect Rowsend
Section titled “Connect Rowsend”- Open Settings → Companion and choose My server.
- Enter the Server URL (
https://email.example.com) and the API key. - 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.
Settings
Section titled “Settings”| 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 |
Changing the API key
Section titled “Changing the API key”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.
Sending while your computer is off
Section titled “Sending while your computer is off”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:
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.
SMTP bounces
Section titled “SMTP bounces”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.
Through Cloudflare
Section titled “Through Cloudflare”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.
-
Generate a secret with
openssl rand -base64 32, set it asROWSEND_INBOUND_SECRETfor the companion, and restart it. -
Download the Worker,
rowsend-bounces.js. It’s attached to every Rowsend release. -
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 -
Give it the same secret when asked:
Terminal window npx wrangler secret put INBOUND_SECRET --name rowsend-bouncesWithout Node.js, you can create a Worker named
rowsend-bouncesin the Cloudflare dashboard instead, paste the file into its code editor, and addCOMPANION_URL,BOUNCE_DOMAINand theINBOUND_SECRETsecret under its settings. -
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.comto therowsend-bouncesWorker. Bounce addresses look likeb+<id>@bounces.example.com, so subaddressing sends them all through that one rule. -
In Project settings → Sending, enter it as the Bounce domain.
On port 25
Section titled “On port 25”- Uncomment
BOUNCE_SMTP_PORTand the25:2525port incompose.yaml, and open port 25 in your server’s firewall. Some cloud hosts block port 25 until you ask. - Add an MX record pointing your bounce domain at your server.
- In Project settings → Sending, enter it as the Bounce domain.
Backups
Section titled “Backups”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.
Images in a bucket
Section titled “Images 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.
Updating
Section titled “Updating”docker compose pull && docker compose up -dDatabase migrations run when the companion starts.
Can we count your visit?
Google Analytics shows us which pages help. It only sets cookies if you allow it. Privacy policy