A practical guide to operating application email with Postal or Plunk, deploying the services with Coolify, publishing web endpoints through Cloudflare, and onboarding multiple product domains.
For the repeatable product-domain workflow, use the Postal domain onboarding runbook. It covers DNS, API credentials, application storage, delivery webhooks, branded unsubscribe URLs, and throttled marketing campaigns.
Example values used throughout:
example.net— the domain that hosts the Plunk installation.product.example— a placeholder product domain;product-a.exampleandproduct-b.exampledemonstrate a multi-domain setup.203.0.113.10— the public IP address of the Coolify server.
Browser or application
|
v
Cloudflare DNS and optional HTTP proxy
|
v
Coolify reverse proxy and TLS
|
v
Self-hosted Plunk
|-- dashboard
|-- API
|-- landing page
|-- documentation
|-- PostgreSQL, Redis, MinIO, ntfy
|
v
Amazon SES and SNS
|
v
Recipient mailboxes
The Plunk installation and the domains that send email are separate concepts. One Plunk instance can serve many verified sending domains.
- A Linux server already connected to a working Coolify installation.
- A domain using Cloudflare authoritative DNS.
- An AWS account with access to SES, IAM, and SNS.
- Docker Compose knowledge sufficient to review a deployment before exposing it.
- A mailbox for receiving DMARC reports and test messages.
Plunk's official self-hosting requirements are Docker, Docker Compose, AWS SES, and a domain with service subdomains. Read the current Plunk self-hosting documentation before deploying because environment variables and images can change.
Use dedicated hostnames for the Plunk control plane:
| Purpose | Example | Public? |
|---|---|---|
| Dashboard | plunk.example.net |
Yes; restrict access if possible |
| API and webhooks | plunk-api.example.net |
Yes |
| Landing page | plunk-site.example.net |
Optional |
| Documentation | plunk-docs.example.net |
Optional |
| SMTP relay | smtp.example.net |
Optional; DNS-only in Cloudflare |
For each product that sends email, reserve a separate MAIL FROM label:
| Purpose | Example |
|---|---|
| Visible From address | hello@product.example |
| DKIM records | <selector>._domainkey.product.example |
| DMARC record | _dmarc.product.example |
| Custom MAIL FROM | plunk.product.example |
| Branded preferences | mail.product.example |
Do not use plunk.product.example as a website CNAME if it is also the MAIL FROM hostname. MAIL FROM needs MX and TXT records, which cannot coexist with a CNAME at the same name.
The preferences hostname is an ordinary HTTPS website. Keep it separate from the custom MAIL FROM hostname: mail.product.example serves unsubscribe pages, while plunk.product.example is used by SES for the envelope sender and bounce handling.
Use the same AWS region everywhere: IAM testing, SES identities, SNS, configuration sets, and Plunk environment variables.
Create a dedicated IAM user such as plunk-ses. Do not use an AWS root credential or a general-purpose administrator key.
The current Plunk documentation lists this policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SetIdentityMailFromDomain",
"ses:GetIdentityDkimAttributes",
"ses:SendRawEmail",
"ses:GetIdentityVerificationAttributes",
"ses:VerifyDomainDkim",
"ses:ListIdentities",
"ses:SetIdentityFeedbackForwardingEnabled"
],
"Resource": "*"
}
]
}Create an access key for this user and place it only in Coolify's secret environment variables.
-
Open Amazon SNS in the same region as SES.
-
Create a Standard topic, for example
plunk-ses-events. -
Add an HTTPS subscription whose endpoint is:
https://plunk-api.example.net/webhooks/sns -
Deploy Plunk and confirm that the SNS subscription becomes confirmed. If it does not, inspect the Plunk API logs.
Create two configuration sets:
plunk-tracking- Event destination: the SNS topic above.
- Events: sends, deliveries, opens, clicks, bounces, and complaints.
plunk-no-tracking- Event destination: the same SNS topic.
- Events: sends, deliveries, bounces, and complaints.
The second set lets projects disable engagement tracking while retaining essential delivery feedback.
New SES accounts normally begin in the sandbox. In sandbox mode, sending is limited to verified recipients and a small account quota. Use it for testing, then request production access from the SES account dashboard before sending real application or newsletter traffic.
In the request, accurately describe:
- Why recipients expect the messages.
- How consent is recorded.
- How unsubscribe requests are honored.
- How bounces and complaints are suppressed.
- Your expected daily and peak sending volume.
Do not start a bulk campaign until production access and an appropriate quota are visible in the selected SES region.
Official reference: Plunk — AWS SES setup.
In Cloudflare, open DNS → Records for example.net and create these records:
| Type | Name | Target | Proxy initially |
|---|---|---|---|
| A | plunk |
203.0.113.10 |
DNS only |
| A | plunk-api |
203.0.113.10 |
DNS only |
| A | plunk-site |
203.0.113.10 |
DNS only |
| A | plunk-docs |
203.0.113.10 |
DNS only |
| A | smtp |
203.0.113.10 |
DNS only |
You can use CNAME records instead if you already maintain a stable origin hostname.
Start with DNS-only records so Coolify can obtain certificates and so troubleshooting does not mix origin problems with Cloudflare proxy behavior. After HTTPS works directly, the four HTTP hostnames can be proxied through Cloudflare. Keep the SMTP hostname DNS-only; Cloudflare's normal orange-cloud HTTP proxy does not proxy SMTP ports.
For proxied web hostnames, use SSL/TLS → Full (strict) after Coolify has a valid origin certificate. Do not use Flexible mode.
Official references:
Use the latest upstream docker-compose.yml as the source of truth. Pin a tested Plunk image version for production instead of permanently following latest.
In Coolify:
- Open the target project and environment.
- Choose New Resource → Docker Compose Empty.
- Paste a reviewed copy of Plunk's current Compose file.
- Keep persistent volumes for PostgreSQL, Redis, MinIO, Plunk data, and ntfy.
- Do not expose PostgreSQL or Redis ports publicly.
- Remove unnecessary host port mappings for MinIO and ntfy unless an administrator explicitly needs them. Internal containers can reach those services by service name.
- If Coolify reports intermittent proxy routing or 504 errors, remove the explicit
networks:entries and the top-level custom network. Coolify creates an isolated network and connects its reverse proxy automatically. See Coolify's Docker Compose networking guidance.
Review upstream changes before every upgrade. A Compose file copied months ago is not a safe substitute for current release notes.
Set variables in Coolify, not in a committed .env file.
Generate independent secrets locally:
openssl rand -base64 48 # JWT_SECRET
openssl rand -base64 48 # DB_PASSWORD
openssl rand -base64 48 # MINIO_ROOT_PASSWORDUse values similar to the following in Coolify:
NODE_ENV=production
USE_HTTPS=true
API_DOMAIN=plunk-api.example.net
DASHBOARD_DOMAIN=plunk.example.net
LANDING_DOMAIN=plunk-site.example.net
WIKI_DOMAIN=plunk-docs.example.net
JWT_SECRET=<random-secret>
DB_PASSWORD=<different-random-secret>
MINIO_ROOT_USER=plunk
MINIO_ROOT_PASSWORD=<different-random-secret>
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=<iam-access-key-id>
AWS_SES_SECRET_ACCESS_KEY=<iam-secret-access-key>
SES_CONFIGURATION_SET=plunk-tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking
MAIL_FROM_SUBDOMAIN=plunk
DISABLE_SIGNUPS=false
AUTO_PROJECT_DISABLE=trueImportant details:
- Create the first administrator before changing
DISABLE_SIGNUPStotrue. - Keep
AUTO_PROJECT_DISABLE=trueso abnormal bounce or complaint rates can suspend sending. - Do not set an email rate higher than the SES account's actual per-second quota.
- Do not expose
JWT_SECRET, database passwords, AWS keys, or Plunk secret API keys to browser code. - Ensure the Compose file passes Coolify variables into the correct service. Merely creating a variable in the UI does not help if the Compose service never references it.
See the current Plunk environment variable reference for the complete list.
The official Plunk container runs an internal Nginx proxy on container port 80 and routes by the HTTP Host header. Attach all four HTTPS hostnames to the plunk service in Coolify:
https://plunk.example.net,
https://plunk-api.example.net,
https://plunk-site.example.net,
https://plunk-docs.example.net
If your edited Compose changes the internal listener port, add that container port to each Coolify domain as documented by Coolify. Do not add :443 merely because the public URL is HTTPS; the Coolify proxy terminates TLS and forwards to the container's internal HTTP port.
Deploy the stack and wait for PostgreSQL, Redis, MinIO, ntfy, and Plunk health checks to pass.
Only expose ports 465 and 587 if you actually need SMTP. SMTP needs a DNS-only hostname and a valid TLS certificate available to the Plunk container. API-based sending is simpler for most web applications and requires fewer open ports.
Check DNS:
dig +short plunk.example.net A
dig +short plunk-api.example.net ACheck HTTPS and the API health endpoint:
curl -I https://plunk.example.net
curl -fsS https://plunk-api.example.net/healthConfirm that:
- The dashboard loads without mixed-content errors.
- The API health endpoint returns success.
- Browser requests from the dashboard target the public HTTPS API hostname.
- The SNS subscription is confirmed.
- Coolify shows the stack as healthy.
After direct HTTPS works, optionally enable Cloudflare proxying for the HTTP hostnames and repeat the checks.
- Sign in to the Plunk dashboard.
- Create a project for one product or a clearly related group of products.
- Set its name, logo, default locale, and tracking preference.
- Generate a secret API key.
- Store the key in the backend application's secret manager.
Never place the secret key in frontend JavaScript, a mobile application, a public repository, screenshots, support tickets, or analytics events. Rotate it immediately if exposed.
Applications authenticate like this:
Authorization: Bearer <PLUNK_SECRET_KEY>In the project, open Settings → Domains, add product.example, and let Plunk generate the exact SES verification records.
Do not invent or copy DNS values from this guide. DKIM selectors and targets are unique to the AWS account and identity.
Plunk normally shows:
- Three DKIM CNAME records.
- One SPF-related TXT record.
- One bounce-feedback MX record.
- Optional custom MAIL FROM MX and TXT records.
Copy every Name, Type, Value, and Priority exactly.
| Record | Cloudflare proxy setting | Common mistake |
|---|---|---|
| DKIM CNAME | DNS only | Orange-cloud proxy enabled |
| SPF TXT | Not applicable | Creating a second SPF record |
| Bounce MX | Not applicable | Omitting the MX priority |
| MAIL FROM MX | Not applicable | Reusing a hostname that already has a CNAME |
| MAIL FROM TXT | Not applicable | Adding it at the apex instead of the displayed subdomain |
| DMARC TXT | Not applicable | Starting with p=reject before monitoring |
Cloudflare may automatically append the zone name. If Plunk displays a complete hostname, verify that Cloudflare did not turn it into something like selector._domainkey.product.example.product.example.
A hostname must have only one SPF policy. If an SPF TXT record already exists, merge the mechanisms into a single record. Do not publish two separate TXT records beginning with v=spf1.
Use the exact mechanism shown by Plunk. A conceptual merged policy looks like:
v=spf1 include:_spf.existing-provider.example include:<value-shown-by-plunk> ~all
Start with monitoring:
Type: TXT
Name: _dmarc
Value: v=DMARC1; p=none; rua=mailto:dmarc-reports@product.example
Use a real reporting mailbox you control. Review reports for all legitimate senders, then progress deliberately from p=none to p=quarantine and finally p=reject.
DNS can verify in minutes but may take much longer depending on caching. Plunk checks automatically and also provides a manual re-check.
Useful commands:
dig CNAME <selector>._domainkey.product.example
dig TXT product.example
dig MX plunk.product.example
dig TXT plunk.product.example
dig TXT _dmarc.product.exampleThe domain is ready only when Plunk shows all required records as verified.
Official reference: Plunk — verifying domains.
Repeat section 7 for each domain. Do not reuse one domain's DKIM records on another domain.
A reasonable organization is:
- One Plunk project per product when teams, branding, templates, suppression rules, or API keys should be isolated.
- One project for several closely related domains when the same operators and subscription policy manage all of them.
Remember that contact subscription state is project-scoped. Decide whether an unsubscribe should affect one product or an entire family of products before combining domains in a project.
Use a verified From address and the self-hosted API URL:
curl -fsS https://plunk-api.example.net/v1/send \
-H "Authorization: Bearer $PLUNK_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: first-production-test-001" \
--data '{
"to": "recipient@example.org",
"subject": "Plunk delivery test",
"body": "<p>This is a transactional delivery test.</p>",
"from": {
"name": "Example Product",
"email": "hello@product.example"
}
}'Use a recipient you control. Confirm:
- Plunk records the send and subsequent delivery event.
- SES records the event in the expected configuration set.
- SPF, DKIM, and DMARC pass in the received message headers.
- The visible From address and reply behavior are correct.
- The message is not duplicated when the same idempotency key is retried.
An API success means the request was accepted; it does not by itself prove inbox delivery.
Do not send newsletters through a purely transactional template.
Plunk provides per-recipient variables:
{{unsubscribeUrl}}{{subscribeUrl}}{{manageUrl}}
Behavior depends on the template type:
- Marketing: respects opt-out and automatically gets Plunk's localized unsubscribe footer.
- Headless: respects opt-out, but your template must include
{{unsubscribeUrl}}or{{manageUrl}}. - Transactional: ignores marketing subscription state and does not receive an unsubscribe footer. Reserve it for messages that are genuinely necessary to provide the service.
Configure the project name and logo if Plunk's standard hosted preference pages are sufficient. The rest of this section explains how to give each sending domain its own URLs instead:
https://mail.product-a.example/manage/<contact-id>
https://mail.product-a.example/unsubscribe/<contact-id>
https://mail.product-b.example/manage/<contact-id>
https://mail.product-b.example/unsubscribe/<contact-id>
A DNS record alone cannot customize, rewrite, or safely proxy these operations. Run a small preferences service that owns the mail.* hostnames and forwards confirmed subscription changes to Plunk.
For a fully custom footer, prefer a Headless marketing template and include the custom links yourself. A Marketing template may also add Plunk's standard footer, producing duplicate controls or a URL on the Plunk host. Do not switch marketing mail to Transactional merely to remove that footer; transactional delivery bypasses marketing subscription state.
Marketing email
|-- HTML footer: mail.product-a.example/manage/<contact-id>
|-- List-Unsubscribe: mail.product-a.example/unsubscribe/<contact-id>
|
v
Cloudflare DNS and HTTPS
|
v
Coolify routes by the HTTP Host header
|
v
Branded preferences service
|-- GET /manage/<id> shows available choices
|-- GET /unsubscribe/<id> shows a confirmation page
|-- POST /unsubscribe/<id> performs the opt-out
|-- POST /subscribe/<id> performs the opt-in
|
v
Plunk public contact operation
POST /contacts/public/<id>/unsubscribe or /subscribe
Do not unsubscribe on a normal GET. Mail security scanners and link preview systems may open links automatically. Use GET to display a confirmation page and POST to make the change. The POST route also supports the one-click request generated by compatible mailbox providers.
Plunk contact subscription state belongs to a project, not to a sender domain. If product-a.example and product-b.example use the same Plunk project, unsubscribing the shared contact affects marketing mail from both domains.
Use separate Plunk projects when each product needs independent:
- consent and unsubscribe state;
- API keys and team access;
- templates and branding;
- suppression, analytics, or retention policies.
Do not imply that a contact has per-domain preferences if the backend actually stores one shared project-level boolean.
In the Cloudflare zone for every product domain, point mail to the Coolify server:
| Type | Name | Target | Proxy initially |
|---|---|---|---|
| A | mail |
203.0.113.10 |
DNS only |
Repeat this in product-a.example, product-b.example, and every later product zone. A CNAME to a stable origin hostname is also acceptable.
Start DNS-only while Coolify obtains certificates. After direct HTTPS works, Cloudflare proxying is optional. If enabled, use Full (strict) TLS.
The following dependency-free Node.js example selects branding from the request hostname, validates the contact ID, and exposes confirmation-based subscribe and unsubscribe routes. Save it as server.js in a small private or public repository; it contains no credentials.
const http = require('node:http');
const { URL } = require('node:url');
const port = Number(process.env.PORT || 3000);
const plunkApiBase = (process.env.PLUNK_API_BASE || 'https://plunk-api.example.net')
.replace(/\/$/, '');
const brands = {
'mail.product-a.example': {
name: 'Product A',
website: 'https://product-a.example',
color: '#2563eb',
},
'mail.product-b.example': {
name: 'Product B',
website: 'https://product-b.example',
color: '#7c3aed',
},
};
const contactIdPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function page(brand, title, content) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(title)} · ${escapeHtml(brand.name)}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f8fafc;color:#0f172a;
font:16px/1.55 system-ui,sans-serif}main{min-height:100vh;display:grid;
place-items:center;padding:24px}.card{width:min(100%,520px);background:#fff;
border:1px solid #e2e8f0;border-radius:20px;padding:32px}h1{margin-top:0}
.actions{display:flex;gap:10px;flex-wrap:wrap}button,a{border:0;
border-radius:10px;padding:12px 16px;font:inherit;font-weight:700;
text-decoration:none;cursor:pointer}.primary{background:${brand.color};color:#fff}
.secondary{background:#eef2f7;color:#0f172a}
</style>
</head>
<body><main><section class="card">
<strong>${escapeHtml(brand.name)}</strong>${content}
</section></main></body>
</html>`;
}
function respond(res, status, html) {
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy':
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; " +
"base-uri 'none'; frame-ancestors 'none'",
'referrer-policy': 'no-referrer',
'strict-transport-security': 'max-age=31536000; includeSubDomains',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'x-robots-tag': 'noindex, nofollow',
});
res.end(html);
}
async function updateSubscription(contactId, operation) {
const response = await fetch(
`${plunkApiBase}/contacts/public/${contactId}/${operation}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
},
);
if (!response.ok) throw new Error(`Plunk returned ${response.status}`);
}
http.createServer(async (req, res) => {
const host = String(req.headers.host || '').split(':')[0].toLowerCase();
const brand = brands[host];
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('ok');
}
if (!brand) return respond(res, 404, '<h1>Unknown preferences domain</h1>');
const url = new URL(req.url, `https://${host}`);
const match = url.pathname.match(/^\/(manage|unsubscribe|subscribe)\/([^/]+)\/?$/i);
if (!match || !contactIdPattern.test(match[2])) {
return respond(res, 404, page(brand, 'Not found',
'<h1>Link not found</h1><p>This preferences link is invalid.</p>'));
}
const operation = match[1].toLowerCase();
const contactId = match[2];
if (req.method === 'GET' && operation === 'manage') {
return respond(res, 200, page(brand, 'Email preferences', `
<h1>Email preferences</h1>
<p>Choose whether to receive optional product news and newsletters.</p>
<div class="actions">
<form action="/subscribe/${contactId}" method="post">
<button class="primary" type="submit">Subscribe</button>
</form>
<form action="/unsubscribe/${contactId}" method="post">
<button class="secondary" type="submit">Unsubscribe</button>
</form>
</div>`));
}
if (req.method === 'GET' && operation === 'unsubscribe') {
return respond(res, 200, page(brand, 'Unsubscribe', `
<h1>Unsubscribe from ${escapeHtml(brand.name)}?</h1>
<p>Optional product news and newsletters will stop.</p>
<div class="actions">
<form method="post">
<button class="primary" type="submit">Unsubscribe</button>
</form>
<a class="secondary" href="${brand.website}">Cancel</a>
</div>`));
}
if (req.method === 'GET' && operation === 'subscribe') {
return respond(res, 200, page(brand, 'Subscribe', `
<h1>Subscribe to ${escapeHtml(brand.name)}?</h1>
<p>Optional product news and newsletters will resume.</p>
<form method="post">
<button class="primary" type="submit">Subscribe</button>
</form>`));
}
if (req.method === 'POST' &&
(operation === 'unsubscribe' || operation === 'subscribe')) {
req.resume();
try {
await updateSubscription(contactId, operation);
const title = operation === 'unsubscribe'
? 'You are unsubscribed'
: 'You are subscribed';
return respond(res, 200, page(brand, title,
`<h1>${title}</h1><p>Your email preferences were updated.</p>`));
} catch (error) {
console.error('Preference update failed', {
host,
operation,
message: error instanceof Error ? error.message : String(error),
});
return respond(res, 502, page(brand, 'Please try again',
'<h1>Update failed</h1><p>Please wait and try the link again.</p>'));
}
}
res.writeHead(405, { allow: 'GET, POST' });
res.end();
}).listen(port, '0.0.0.0');The public subscribe/unsubscribe operation does not require your Plunk secret API key. Do not put that key in this service, its HTML, or the recipient's browser. Re-check the endpoint against the version of Plunk you deploy because self-hosted APIs can change between releases.
Add a minimal Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node server.js ./server.js
USER node
EXPOSE 3000
CMD ["node", "server.js"]Deploy this repository as a separate Coolify application. Set:
PORT=3000
PLUNK_API_BASE=https://plunk-api.example.netAttach every preferences hostname to this one application:
https://mail.product-a.example
https://mail.product-b.example
The internal application port is 3000. Coolify terminates TLS and routes each hostname to the same container. The application chooses the correct brand using the HTTP Host header. Add new domains to both the static brands allowlist and Coolify's domain list before using them in email.
Create or look up the Plunk contact before sending so the backend has its contact ID. Do not set an existing contact back to subscribed: true during an upsert; resubscription must follow an explicit consent action. Select the preferences hostname from the domain in the configured From address; never accept an arbitrary preferences base URL from an end user.
const preferenceBaseBySenderDomain = {
'product-a.example': 'https://mail.product-a.example',
'product-b.example': 'https://mail.product-b.example',
};
function preferenceBaseFor(fromEmail) {
const senderDomain = fromEmail.toLowerCase().split('@')[1];
const base = preferenceBaseBySenderDomain[senderDomain];
if (!base) throw new Error(`No preferences domain for ${senderDomain}`);
return base;
}
const contact = await upsertOrFindPlunkContact(recipientEmail);
const preferencesBase = preferenceBaseFor(fromEmail);
const unsubscribeUrl = `${preferencesBase}/unsubscribe/${contact.id}`;
const manageUrl = `${preferencesBase}/manage/${contact.id}`;
const body = `
<p>Your newsletter content.</p>
<p><a href="${manageUrl}">Manage email preferences</a></p>
<p><a href="${unsubscribeUrl}">Unsubscribe</a></p>`;
await sendThroughPlunk({
to: recipientEmail,
from: { name: productName, email: fromEmail },
subject,
body,
headers: {
'List-Unsubscribe': `<${unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
});The helper names above are placeholders for your application's Plunk API wrapper. Keep contact creation, sender-domain selection, and sending on the backend.
For each sender domain:
- Send to a mailbox you control.
- Inspect the raw message and confirm
List-Unsubscribeuses the matchingmail.<domain>hostname. - Open the HTML Manage preferences link and confirm the correct brand is displayed.
- Open the unsubscribe link with
GETand confirm it does not immediately change state. - Submit the confirmation form and confirm Plunk marks the contact unsubscribed.
- Confirm the next marketing send is suppressed.
- Subscribe again and confirm marketing delivery resumes.
- Verify essential transactional mail still behaves according to your documented policy.
- Repeat for every domain; success on one hostname does not prove that the others have DNS, TLS, or Coolify routing configured correctly.
- Allow only explicitly configured hostnames; do not reflect an arbitrary
Hostheader into links or HTML. - Validate contact IDs before calling Plunk.
- Keep state changes on
POST, neverGET. - Apply rate limiting at Cloudflare or the origin.
- Return
Cache-Control: no-storeandX-Robots-Tag: noindex, nofollow. - Use a restrictive Content Security Policy and disallow framing.
- Log failures without logging full URLs, contact IDs, email addresses, authorization headers, or customer data.
- Monitor the
/healthzendpoint and failed Plunk responses. - Keep the service stateless and deploy at least one tested, pinned image version.
- Test again after Plunk upgrades.
Official reference: Plunk — unsubscribe and preferences pages.
- Disable public signups after creating the required administrators.
- Give each person an individual account.
- Rotate AWS and Plunk API keys periodically.
- Keep credentials in Coolify's secret variables, not Compose defaults.
- Never log authorization headers or full webhook payloads containing personal data.
- Protect the dashboard with Cloudflare Access, a VPN, or an equivalent identity-aware layer if practical.
- Public: ports 80 and 443 through Coolify's proxy.
- Optional: SMTP 465 and 587 only when required.
- Private: PostgreSQL, Redis, MinIO administration, and internal ntfy endpoints.
- Restrict the server firewall and keep SSH key-only.
Back up at least:
- PostgreSQL data with tested logical dumps.
- Persistent object storage used for attachments.
- Coolify resource configuration and secret inventory.
- The exact Plunk image version and Compose revision.
Redis is not a substitute for PostgreSQL backups. Test restoration on a separate environment; an untested backup is only a hope.
Alert on:
- Plunk API health failures.
- PostgreSQL, Redis, or object-storage failures.
- SES bounce and complaint rates.
- SES daily quota and per-second rate usage.
- Coolify deployment failures and expiring certificates.
- A sudden increase in sends, contacts, or authentication failures.
- Confirm the Plunk container is healthy and listens on port 80.
- Ensure all four HTTPS domains are attached to the
plunkservice. - Remove conflicting explicit Docker networks when using Coolify-managed networking.
- Confirm the HTTP
Hostheader matchesAPI_DOMAIN,DASHBOARD_DOMAIN,LANDING_DOMAIN, orWIKI_DOMAIN.
- Confirm
USE_HTTPS=true. - Confirm
API_DOMAINhas no scheme or path. - Confirm
https://plunk-api.example.net/healthworks externally. - Check browser console errors and Plunk API logs.
- Temporarily make the web DNS records DNS-only.
- Confirm ports 80 and 443 reach the Coolify server.
- Confirm no other reverse proxy is binding the same ports unexpectedly.
- Verify that the hostname resolves to the intended origin.
- Compare each live DNS answer with Plunk's displayed value.
- Ensure DKIM CNAME records are DNS-only.
- Check for duplicated zone suffixes in Cloudflare.
- Ensure there is only one SPF record at a given hostname.
- Check MX priority and the selected AWS region.
- Confirm SPF, DKIM, and DMARC alignment in the received headers.
- Move gradually from a low, consistent sending volume.
- Send only to people who expect the message.
- Remove hard bounces and complaints immediately.
- Avoid URL shorteners, misleading subjects, image-only messages, and purchased lists.
- Provide plain, working unsubscribe controls for marketing mail.
Do not replace the apex MX records used by Google Workspace, Microsoft 365, or another mailbox provider merely to enable Plunk inbound mail. Use a dedicated inbound subdomain unless you deliberately intend to replace the existing mail system. See Plunk — receiving emails.
- Plunk uses pinned, reviewed container versions.
- Persistent volumes exist and restore tests pass.
- PostgreSQL, Redis, and MinIO administration are not public.
- Dashboard, API, landing, and docs hostnames have valid HTTPS.
- Cloudflare web proxy uses Full (strict), if enabled.
- SMTP and SES verification CNAME records are DNS-only.
- AWS credentials belong to a dedicated least-privilege IAM user.
- SNS feedback subscription is confirmed.
- Tracking and no-tracking SES configuration sets work.
- SES production access and quotas match expected traffic.
- Every sending domain passes DKIM and SPF verification.
- Every sending domain has a monitored DMARC policy.
- There is only one SPF record per hostname.
- Marketing templates honor subscription state and include unsubscribe controls.
- Every
mail.<product-domain>hostname has valid DNS and HTTPS. - Coolify routes every preferences hostname to the host-aware preferences service.
-
GETdisplays a confirmation page and never changes subscription state. -
List-Unsubscribeand HTML footer links use the domain matching the sender. - One-click unsubscribe was tested for every sending domain.
- Transactional templates are used only for necessary service messages.
- A real test message passes SPF, DKIM, and DMARC.
- No credentials or personal data are committed to Git.
- Plunk self-hosting
- Plunk environment variables
- Plunk AWS SES setup
- Plunk domain verification
- Plunk unsubscribe pages
- RFC 8058 — one-click email unsubscribe
- Coolify Docker Compose
- Coolify domains
- Cloudflare DNS records
- Amazon SES getting started
This is an independent deployment guide, not official documentation for Plunk, Coolify, Cloudflare, or AWS. Email laws and provider requirements vary by country and use case. You are responsible for consent, privacy, retention, unsubscribe compliance, infrastructure security, and sender reputation.
This appendix is an alternative delivery path for operators who cannot obtain Amazon SES production access or who intentionally want to operate their own delivery infrastructure. It adds to the Plunk/SES guide above; it does not replace it.
Postal is a self-hosted mail delivery platform for applications. It accepts messages through an HTTP API or authenticated SMTP, signs them, queues them, delivers them directly to recipient mail exchangers, records delivery attempts, and processes bounces. It is not a personal mailbox server and it is not a drop-in replacement for Plunk's contact, campaign, template, or unsubscribe features.
Use this architecture when you are prepared to own IP reputation, abuse prevention, DNS, upgrades, backups, bounce handling, and deliverability. A successful installation does not guarantee inbox placement.
Application or campaign service
|-- consent and contact state
|-- templates and per-domain branding
|-- transactional/marketing classification
|-- unsubscribe and preference links
|-- rate limiting and idempotency
|
| HTTPS + X-Server-API-Key
v
Postal API
|-- sender-domain validation
|-- DKIM signing
|-- queue and retries
|-- suppression and delivery history
|-- bounce processing and webhooks
|
| SMTP port 25
v
Recipient MX servers
Plunk currently documents Amazon SES as its self-hosted delivery backend. Do not assume that changing an AWS environment variable will make Plunk send through Postal. The safe integration choices are:
- Keep Plunk/SES for the traffic that SES is authorized to send and call Postal directly from your backend for the Postal route.
- Put a small, tested provider abstraction in your application so
EMAIL_PROVIDER=ses,EMAIL_PROVIDER=postal, or a similar setting selects the transport and the matching unsubscribe behavior. - Build and maintain an explicit Plunk-to-Postal adapter only if you are willing to test it after every Plunk upgrade.
Never mirror a marketing message to two providers. Synchronizing an event or delivery record is different from sending a second copy.
Postal's maintainers strongly recommend a dedicated server with at least 2 CPU cores, 4 GB RAM, and 25 GB disk. A dedicated Postal VPS is preferable because:
- its CPU, disk, and memory cannot destabilize unrelated applications;
- its public IP can be rotated or migrated independently;
- the mail IP does not automatically expose the origin IP of unrelated Cloudflare-proxied sites;
- firewall and incident-response rules are easier to reason about.
Postal can coexist with Coolify on one larger server, but treat that as an advanced deployment. Reserve resources for Postal, keep its database private, and understand that the mail DNS records reveal the shared origin IP. Cloudflare explicitly recommends separating mail infrastructure from web origins where possible.
Before installing, confirm all of the following:
- The VPS has a static public IPv4 address.
- The provider permits direct outbound TCP port 25.
- The IP is not visibly listed on major reputation blocklists.
- The IP is not already associated with suspicious reverse DNS or mail history.
- You control a hostname such as
smtp.example.netand can set reverse DNS. - The server has enough spare memory, disk, and I/O for queues and database growth.
Use a reserved example address in documentation:
Postal server IP: 203.0.113.10
SMTP hostname: smtp.example.net
Admin hostname: postal.example.net
API hostname: postal-api.example.net
Hetzner Cloud blocks outbound ports 25 and 465 by default. Its current documentation says an account can submit a limit request after it has been active for one month and the first invoice has been paid. Approval is case-by-case. Port 587 remains available for relaying through an external delivery provider, but direct delivery from Postal to recipient MX servers requires outbound port 25.
Do not deploy a production queue before the restriction is removed. Postal will accept application requests while being unable to deliver them, which creates a growing queue rather than a working mail service.
Prepare this information for the support request:
- the server or project identifier shown in Hetzner Console;
- the assigned static IP;
- the product type: opt-in transactional notifications and, if applicable, consent-based newsletters;
- realistic initial daily volume and peak hourly rate;
- how recipients give consent and why they expect the messages;
- how one-click unsubscribe works for marketing mail;
- how hard bounces and complaints are suppressed;
- confirmation that purchased, scraped, or cold-contact lists are prohibited;
- confirmation that SPF, DKIM, DMARC, forward DNS, PTR, and TLS will be configured;
- an abuse contact and an incident-response plan;
- a gradual warm-up plan instead of an immediate bulk campaign.
Generic request template:
Subject: Request to unblock outbound SMTP port 25 for an opt-in application mail service
Hello Hetzner Support,
I would like to request removal of the outbound SMTP restriction for the
cloud server identified in this ticket. It will run a self-hosted Postal
instance used only for expected application notifications and consent-based
marketing mail for domains that we control.
Initial volume: approximately [messages per day], increasing gradually only
after delivery, bounce, and complaint metrics remain healthy.
Recipient policy: no purchased, scraped, or cold-contact lists. Transactional
messages are triggered by a user's action or account state. Marketing messages
are sent only to contacts with recorded consent and include working one-click
unsubscribe and preference links.
Abuse controls: authenticated server-side API access, per-application keys,
rate limits, suppression of hard bounces and complaints, monitored queues and
logs, key rotation, and a documented abuse contact.
Authentication and infrastructure: static IP, matching forward and reverse
DNS, stable EHLO hostname, SPF, DKIM, DMARC, TLS, firewall restrictions, and
regular security updates.
Please let me know if you need additional technical or compliance details.
Make every statement truthful. A detailed request cannot compensate for a missing consent process or an ineligible account.
After approval, verify outbound connectivity from the Postal host:
nc -vz gmail-smtp-in.l.google.com 25A successful TCP connection proves that the network path is open; it does not prove that the IP has good reputation.
Official reference: Hetzner Cloud server FAQ — blocked mail ports.
In Cloudflare, create the SMTP hostname first:
| Type | Name | Value | Proxy |
|---|---|---|---|
| A | smtp |
203.0.113.10 |
DNS only |
Then open the server's Networking page in Hetzner Console and set the IPv4 reverse DNS/PTR to smtp.example.net.
Forward and reverse lookups must agree:
dig +short A smtp.example.net
dig +short -x 203.0.113.10Expected result:
smtp.example.net -> 203.0.113.10
203.0.113.10 -> smtp.example.net
Use the same hostname for Postal's SMTP identity/EHLO. One IP can have only one useful PTR value, even when that Postal server sends for many product domains.
Official reference: Hetzner — PTR and reverse DNS.
For a dedicated Postal host, the normal inbound policy is:
| Port | Source | Purpose |
|---|---|---|
| TCP 22 | Trusted administrator IPs or VPN | SSH |
| TCP 25 | Internet | SMTP delivery and bounce handling |
| TCP 80 | Internet or Cloudflare | ACME and HTTP redirect |
| TCP 443 | Cloudflare and administrators as appropriate | Admin, API, tracking, webhooks |
Do not publish MariaDB 3306, Docker's remote API, Redis, metrics, or internal application ports.
Use a Hetzner Cloud Firewall as the outer boundary. Docker-published ports can bypass UFW's normal incoming policy, so a green UFW status alone is not proof that a container port is private. Confirm exposure from another network with nmap or an equivalent scanner.
If Coolify and Postal share a host:
- keep Coolify and Postal databases on private Docker networks;
- publish only Postal SMTP port 25 directly;
- route Postal web/API/tracking traffic through the existing reverse proxy;
- bind any administrative helper port to
127.0.0.1; - use container memory and CPU limits;
- ensure old deployments cannot publish unexpected host ports;
- use the provider firewall because Docker NAT rules may bypass UFW.
Official references: Hetzner Cloud Firewalls and Coolify firewall guidance.
Start with a supported Ubuntu or Debian host, current Docker Engine, and the Docker Compose plugin. Review scripts before executing them; never pipe an unreviewed remote script directly into a production root shell.
Install the small system prerequisites and clone the official helper:
sudo apt update
sudo apt install -y git curl jq
sudo git clone https://github.com/postalserver/install /opt/postal/install
sudo ln -s /opt/postal/install/bin/postal /usr/local/bin/postalPostal v3 requires MariaDB 10.6 or newer. Keep it on a private interface. The official documentation shows a loopback-only container mapping; replace every example password with a long generated value and pin a tested image version:
openssl rand -base64 48Do not use the insecure passwords in Postal's convenience prerequisite script in production.
Bootstrap configuration with the public admin hostname:
sudo postal bootstrap postal.example.netThis creates /opt/postal/config/postal.yml, signing.key, and the proxy configuration. Protect the directory:
sudo chown -R root:root /opt/postal/config
sudo chmod 700 /opt/postal/config
sudo chmod 600 /opt/postal/config/postal.yml /opt/postal/config/signing.keyReview the generated version-2 configuration instead of pasting a stale complete file. At minimum, make these identities consistent:
version: 2
postal:
web_hostname: postal.example.net
web_protocol: https
smtp_hostname: smtp.example.net
dns:
mx_records:
- smtp.example.net
spf_include: spf.postal.example.net
return_path_domain: rp.postal.example.net
route_domain: routes.postal.example.net
track_domain: track.postal.example.net
web_server:
default_bind_address: 127.0.0.1
default_port: 5000
smtp_server:
default_bind_address: 0.0.0.0
default_port: 25
tls_enabled: true
tls_certificate_path: /config/smtp.cert
tls_private_key_path: /config/smtp.keyUse a certificate from a recognized CA for smtp.example.net. The certificate and key must be available inside the Postal containers under /config. Postal does not enable SMTP TLS by default; without this section, it may advertise authentication methods without offering STARTTLS. Restart Postal after certificate renewal so it loads the new files.
Initialize the schema and create the first administrator interactively:
sudo postal initialize
sudo postal make-user
sudo postal start
sudo postal statusDo not expose the admin interface until its first user exists. Put the dashboard behind Cloudflare Access, OIDC, a VPN, or a comparable identity-aware control. If a reverse proxy already owns ports 80 and 443, route to Postal's loopback web listener rather than starting a second public proxy on the same ports.
Official references:
Use DNS only for every hostname involved in SMTP. Cloudflare's normal proxy does not proxy SMTP port 25.
| Type | Name | Value | Priority | Proxy |
|---|---|---|---|---|
| A | smtp |
203.0.113.10 |
— | DNS only |
| TXT | spf.postal |
v=spf1 ip4:203.0.113.10 -all |
— | — |
| A | rp.postal |
203.0.113.10 |
— | DNS only |
| MX | rp.postal |
smtp.example.net |
10 | — |
| TXT | rp.postal |
v=spf1 include:spf.postal.example.net -all |
— | — |
| MX | routes.postal |
smtp.example.net |
10 | — |
| A | track |
203.0.113.10 |
— | DNS only initially |
| A or CNAME | postal |
Postal web origin | — | Proxied after origin TLS works |
| A or CNAME | postal-api |
Postal API origin | — | Proxied after origin TLS works |
Generate the return-path DKIM value with Postal's documented command and publish the exact output. Do not copy a public key from another installation:
sudo postal default-dkim-recordThe proxy can expose different policies on the same Postal web service:
postal.example.net: dashboard, restricted by Cloudflare Access;postal-api.example.net: only/api/, with rate limits and Postal API-key authentication;track.example.net: tracking endpoint, with the requiredX-Postal-Track-Host: 1upstream header.
Cloudflare Access is not enough if an attacker can connect to the origin IP with the same Host header. Restrict origin traffic to Cloudflare IP ranges, use Cloudflare Tunnel or Authenticated Origin Pulls, or apply an origin-only shared-header/mTLS design. Keep SMTP port 25 directly reachable because it cannot use the normal HTTP proxy.
Official references:
In Postal's restricted dashboard:
- Create an organization for the operator or team.
- Create one mail server for a product or an intentionally shared trust boundary.
- Set a conservative per-server send limit.
- Create a separate HTTP API credential for each backend application.
- Create SMTP credentials only when an application truly cannot use the API.
- Store credentials only in backend secret storage.
Separate mail servers or organizations when products need isolated credentials, limits, suppressions, logs, retention, or incident response.
Prefer the HTTP API because it needs only HTTPS and avoids distributing SMTP passwords. Postal authenticates API sends with:
X-Server-API-Key: <POSTAL_SERVER_API_KEY>Generic test using a mailbox you control:
curl -fsS https://postal-api.example.net/api/v1/send/message \
-H "X-Server-API-Key: $POSTAL_SERVER_API_KEY" \
-H "Content-Type: application/json" \
--data '{
"to": ["recipient@example.org"],
"from": "Example Product <hello@product-a.example>",
"subject": "Postal delivery test",
"plain_body": "This is a controlled delivery test.",
"html_body": "<p>This is a controlled delivery test.</p>"
}'Official reference: Postal HTTP API.
Add every sender domain in the Postal mail server. Postal displays the exact DKIM and return-path records that domain needs. Copy those values exactly into the matching Cloudflare zone and keep verification records DNS-only.
At the product-domain apex, merge Postal into the existing SPF policy instead of creating a second SPF record:
v=spf1 include:spf.postal.example.net include:<other-authorized-provider> ~all
Start DMARC in monitoring mode with a reporting mailbox you control:
Name: _dmarc
Type: TXT
Value: v=DMARC1; p=none; rua=mailto:dmarc-reports@product-a.example; adkim=r; aspf=r
After reports show that all legitimate senders align, move deliberately to p=quarantine and then p=reject. Change SPF from ~all to -all only after all valid senders are known.
Validate from a public resolver:
dig TXT product-a.example
dig TXT _dmarc.product-a.example
dig TXT <postal-selector>._domainkey.product-a.example
dig MX rp.postal.example.net
dig TXT rp.postal.example.netSend a test and inspect the raw headers. Do not proceed to a campaign until SPF, DKIM, and DMARC all pass and align with the visible From domain.
Postal delivers messages; it does not decide whether a contact gave marketing consent. Reuse the host-aware preferences service described in section 10 of this guide or another consent system with equivalent behavior.
For every marketing message:
- select the branded preferences hostname from the visible From domain;
- include a visible unsubscribe link in HTML and plain text;
- add
List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Click; - make normal
GETshow a confirmation page rather than immediately changing state; - support the RFC 8058 one-click
POSTrequest; - suppress the contact before the next marketing send;
- keep transactional mail separate and send it only when it is genuinely necessary for the service.
When an application switches providers, it must switch the full marketing contract, not only the send endpoint. For example:
| Provider | Delivery API | Suppression source | Unsubscribe URLs |
|---|---|---|---|
| SES/Plunk | Plunk API | Plunk project contact state | Plunk or the configured branded preferences service |
| Postal | Postal API | Application consent database or preferences service | The matching branded preferences service |
Scheduled jobs must read the provider setting at execution time or store the intended provider explicitly. Otherwise a job created before a switch can send through the old route with the wrong footer.
Network checks from another machine:
nmap -sT -Pn -p 22,25,80,443 203.0.113.10
curl -I https://postal.example.net
curl -I https://postal-api.example.net
openssl s_client -connect smtp.example.net:25 -starttls smtp -servername smtp.example.netOpen-relay check: connect without credentials, issue EHLO, MAIL FROM, and an external RCPT TO, and confirm Postal rejects the recipient with an authentication-required response before DATA. Never complete a relay test to an address you do not control.
Confirm all of the following:
- SSH offers public-key authentication only.
- The dashboard requires the identity-aware access layer.
- A request sent directly to the origin IP cannot bypass dashboard or API policy.
- The API rejects a missing or invalid
X-Server-API-Key. - SMTP advertises STARTTLS and serves the expected certificate.
- Postal is not an open relay.
- MariaDB and internal container ports are not externally reachable.
- PTR, forward DNS, and SMTP EHLO match.
- SPF, DKIM, and DMARC pass in real messages.
- Bounce and complaint events reach the suppression workflow.
- Queue growth, disk usage, and unusual send volume trigger alerts.
- A controlled Gmail, Outlook, and another-provider test does not produce authentication errors.
Start at a low, consistent volume. A new IP with perfect DNS still has no positive reputation. Do not manufacture engagement or send to inactive contacts to "warm" it.
Back up:
- MariaDB with tested logical dumps;
/opt/postal/config, excluding it from public repositories;- the exact Postal and MariaDB versions;
- webhook, domain, credential, suppression, and retention configuration;
- the procedure for restoring DNS and PTR after an IP migration.
Do not retain full message content longer than the product needs. Treat Postal logs as personal data because they can contain addresses, subjects, IPs, and delivery responses.
Postal's standard upgrade is not zero-downtime. Read release notes, create a database backup, schedule a quiet window, and then use the official flow:
cd /opt/postal/install
sudo git pull origin
sudo postal upgrade <tested-version>Pinning <tested-version> makes rollback planning more predictable than automatically taking the newest release. Test SMTP, API sending, DKIM, tracking, webhooks, and branded preference links after every upgrade.
If a key leaks or send volume spikes:
- Disable or rotate the affected Postal credential.
- Stop the responsible application or queue producer.
- Preserve relevant logs without exposing message contents publicly.
- Suppress complaints and hard bounces.
- Check blocklists and provider feedback.
- Notify the hosting provider if abuse occurred.
- Fix the root cause before resuming gradual delivery.
Official reference: Postal upgrades.
- Hetzner explicitly removed the outbound port-25 restriction.
- A dedicated static IP was checked before use.
-
smtp.example.netforward DNS and PTR match the IP. - The provider firewall exposes only intentional ports.
- Docker-published ports were verified externally, not inferred from UFW.
- Postal configuration and signing keys are mode
0600or otherwise equivalently protected. - MariaDB is private, backed up, and restore-tested.
- Dashboard access is identity-restricted and cannot be bypassed through the origin IP.
- API access is HTTPS-only, credentialed, rate-limited, and monitored.
- SMTP STARTTLS uses a valid certificate.
- An unauthenticated external recipient is rejected; the server is not an open relay.
- Each application has its own Postal credential and conservative send limit.
- Every sender domain has unique, verified DKIM and one merged SPF policy.
- DMARC reports are monitored before enforcement is increased.
- Marketing consent, one-click unsubscribe, and suppressions are tested end to end.
- Transactional and marketing message classifications are documented.
- Queue, disk, bounce, complaint, and unusual-volume alerts exist.
- Upgrades, certificate renewal, backup restore, and credential rotation are rehearsed.
- No server IPs, private URLs, credentials, customer data, or real message contents are committed to Git.