Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Self-host application email with Postal or Plunk

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.example and product-b.example demonstrate a multi-domain setup.
  • 203.0.113.10 — the public IP address of the Coolify server.

What you will build

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.

Prerequisites

  • 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.

1. Plan the hostnames

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.

2. Prepare Amazon SES

Use the same AWS region everywhere: IAM testing, SES identities, SNS, configuration sets, and Plunk environment variables.

Create a least-privilege IAM user

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.

Create an SNS feedback topic

  1. Open Amazon SNS in the same region as SES.

  2. Create a Standard topic, for example plunk-ses-events.

  3. Add an HTTPS subscription whose endpoint is:

    https://plunk-api.example.net/webhooks/sns
    
  4. Deploy Plunk and confirm that the SNS subscription becomes confirmed. If it does not, inspect the Plunk API logs.

Create SES configuration sets

Create two configuration sets:

  1. plunk-tracking
    • Event destination: the SNS topic above.
    • Events: sends, deliveries, opens, clicks, bounces, and complaints.
  2. 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.

Understand the SES sandbox

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.

3. Create Cloudflare records for the Plunk services

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:

4. Deploy Plunk in Coolify

Start from the official Compose file

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:

  1. Open the target project and environment.
  2. Choose New Resource → Docker Compose Empty.
  3. Paste a reviewed copy of Plunk's current Compose file.
  4. Keep persistent volumes for PostgreSQL, Redis, MinIO, Plunk data, and ntfy.
  5. Do not expose PostgreSQL or Redis ports publicly.
  6. Remove unnecessary host port mappings for MinIO and ntfy unless an administrator explicitly needs them. Internal containers can reach those services by service name.
  7. 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.

Configure environment variables

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_PASSWORD

Use 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=true

Important details:

  • Create the first administrator before changing DISABLE_SIGNUPS to true.
  • Keep AUTO_PROJECT_DISABLE=true so 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.

Attach the web domains

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.

Optional SMTP relay

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.

5. Verify the Plunk installation

Check DNS:

dig +short plunk.example.net A
dig +short plunk-api.example.net A

Check HTTPS and the API health endpoint:

curl -I https://plunk.example.net
curl -fsS https://plunk-api.example.net/health

Confirm 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.

6. Create a Plunk project

  1. Sign in to the Plunk dashboard.
  2. Create a project for one product or a clearly related group of products.
  3. Set its name, logo, default locale, and tracking preference.
  4. Generate a secret API key.
  5. 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>

7. Add a sending domain to Plunk

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.

Enter the records in Cloudflare

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.

Merge SPF instead of duplicating it

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

Add DMARC

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.

Verify

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.example

The domain is ready only when Plunk shows all required records as verified.

Official reference: Plunk — verifying domains.

8. Add additional sending 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.

9. Send a transactional test

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.

10. Configure marketing unsubscribe behavior

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.

Understand the request flow

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.

Decide whether subscription state is shared

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.

Create the DNS records

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.

Run a host-aware preferences service

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('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

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.net

Attach 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.

Generate domain-specific links in the sending application

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.

Test every domain end to end

For each sender domain:

  1. Send to a mailbox you control.
  2. Inspect the raw message and confirm List-Unsubscribe uses the matching mail.<domain> hostname.
  3. Open the HTML Manage preferences link and confirm the correct brand is displayed.
  4. Open the unsubscribe link with GET and confirm it does not immediately change state.
  5. Submit the confirmation form and confirm Plunk marks the contact unsubscribed.
  6. Confirm the next marketing send is suppressed.
  7. Subscribe again and confirm marketing delivery resumes.
  8. Verify essential transactional mail still behaves according to your documented policy.
  9. Repeat for every domain; success on one hostname does not prove that the others have DNS, TLS, or Coolify routing configured correctly.

Harden the preferences service

  • Allow only explicitly configured hostnames; do not reflect an arbitrary Host header into links or HTML.
  • Validate contact IDs before calling Plunk.
  • Keep state changes on POST, never GET.
  • Apply rate limiting at Cloudflare or the origin.
  • Return Cache-Control: no-store and X-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 /healthz endpoint 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.

11. Production hardening

Access and secrets

  • 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.

Network exposure

  • 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.

Backups

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.

Monitoring

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.

12. Troubleshooting

Coolify shows 502 or 504

  • Confirm the Plunk container is healthy and listens on port 80.
  • Ensure all four HTTPS domains are attached to the plunk service.
  • Remove conflicting explicit Docker networks when using Coolify-managed networking.
  • Confirm the HTTP Host header matches API_DOMAIN, DASHBOARD_DOMAIN, LANDING_DOMAIN, or WIKI_DOMAIN.

The dashboard loads but API calls fail

  • Confirm USE_HTTPS=true.
  • Confirm API_DOMAIN has no scheme or path.
  • Confirm https://plunk-api.example.net/health works externally.
  • Check browser console errors and Plunk API logs.

Coolify cannot issue a certificate

  • 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.

Plunk domain verification is stuck

  • 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.

SES accepts mail but it lands in spam

  • 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.

Existing company mail stops arriving

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.

Final checklist

  • 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.
  • GET displays a confirmation page and never changes subscription state.
  • List-Unsubscribe and 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.

Primary documentation

Disclaimer

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.


Appendix A: AWS-independent delivery with Postal on Hetzner

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.

Architecture and responsibility boundaries

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:

  1. Keep Plunk/SES for the traffic that SES is authorized to send and call Postal directly from your backend for the Postal route.
  2. 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.
  3. 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.

1. Choose the server and IP before installing

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.net and 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

2. Ask Hetzner to unblock mail ports

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 25

A 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.

3. Create forward DNS and reverse DNS

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.10

Expected 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.

4. Configure the network boundary

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.

5. Install Postal from the official helper

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/postal

Postal 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 48

Do 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.net

This 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.key

Review 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.key

Use 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 status

Do 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:

6. Create the Postal infrastructure records in Cloudflare

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-record

The 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 required X-Postal-Track-Host: 1 upstream 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:

7. Create organizations, mail servers, and credentials

In Postal's restricted dashboard:

  1. Create an organization for the operator or team.
  2. Create one mail server for a product or an intentionally shared trust boundary.
  3. Set a conservative per-server send limit.
  4. Create a separate HTTP API credential for each backend application.
  5. Create SMTP credentials only when an application truly cannot use the API.
  6. 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.

8. Verify each sending domain

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.net

Send 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.

9. Keep unsubscribe behavior outside the transport

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-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click;
  • make normal GET show a confirmation page rather than immediately changing state;
  • support the RFC 8058 one-click POST request;
  • 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.

10. Test security and deliverability before production

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.net

Open-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.

11. Backups, retention, upgrades, and incident response

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:

  1. Disable or rotate the affected Postal credential.
  2. Stop the responsible application or queue producer.
  3. Preserve relevant logs without exposing message contents publicly.
  4. Suppress complaints and hard bounces.
  5. Check blocklists and provider feedback.
  6. Notify the hosting provider if abuse occurred.
  7. Fix the root cause before resuming gradual delivery.

Official reference: Postal upgrades.

Postal production checklist

  • Hetzner explicitly removed the outbound port-25 restriction.
  • A dedicated static IP was checked before use.
  • smtp.example.net forward 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 0600 or 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.

About

A secure, practical guide to self-hosting Plunk with Coolify, Cloudflare, and Amazon SES

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors