Table of Contents
- The Economics and Architecture of Self-Hosted Email
- Prerequisites for a LEMP Stack Deployment
- Configuring Nginx Rewrite Rules for Sendy
- Database Initialization and Sendy Configuration
- Amazon SES Integration and IAM Security
- Establishing Email Authority with SPF and DKIM
- Automating Bounce Processing via Amazon SNS
The Economics and Architecture of Self-Hosted Email
Most teams reach for a hosted email platform because the onboarding takes ten minutes. The bill, however, scales with your list. Once you are sending to a few hundred thousand subscribers a month, the per-recipient pricing of a typical SaaS provider starts to look like a subscription you forgot to cancel.
Sendy paired with Amazon SES rewrites that math. SES charges $0.10 per 1,000 emails sent. You buy the Sendy license once and host it yourself. The cost analysis I ran across a few quarters kept landing in the same place: at volume, the delivery layer becomes a rounding error, and what you pay for is the server it runs on.
Why Nginx Instead of Apache
I initially considered Apache, mostly for its native .htaccess support — Sendy ships with one, so the path of least resistance was tempting. But bulk dispatch is a concurrency problem, not a configuration-convenience problem.
During a campaign send, your server fields a flood of simultaneous tracking-pixel and click-redirect requests. Apache's process-per-connection model exhausts worker slots under that load. Nginx, with an event-driven model and a modest worker_connections 1024 setting, absorbs the same burst without thrashing. That is the whole reason the rest of this guide assumes Nginx.
The LEMP Stack at a Glance
LEMP is Linux, Nginx (the "E" is for its pronunciation, engine-x), MySQL, and PHP. Sendy is a PHP application backed by MySQL, so the stack maps cleanly onto its requirements. The remaining sections build that stack from the operating system up.
Prerequisites for a LEMP Stack Deployment
Start with a clean Linux server. A small cloud instance is plenty for the application itself; the heavy lifting of actual mail transport lives in SES, not on your box. Provision the OS, lock down SSH, and confirm you have a domain you control DNS for — that last point matters more than it sounds, and the authentication section will explain why.
Building the Stack with Centmin Mod
Rather than compile each component by hand, I used Centmin Mod to automate the LEMP build. It compiles Nginx, MySQL, and PHP into a consistent environment, which removes the drift you get when you assemble these pieces manually across rebuilds. The unattended installation runs in roughly 15 to 25 minutes depending on your instance's CPU.
Verifying PHP Extensions
Sendy fails quietly when a PHP extension is missing. Before you touch the application, confirm these four are active:
- curl — outbound API and SMTP calls
- simplexml, parsing SES and SNS responses
- mysqli, the database driver
- gettext, localization strings
Run php -m and check the list. If any are absent, rebuild the PHP component through Centmin Mod before proceeding.
Sendy LEMP Deployment Pre-Flight Checklist
- Verify PHP 8.1/8.2 extensions (curl, simplexml, mysqli, gettext) are active.
- Test Nginx rewrite rules for tracking and unsubscribe endpoints.
- Confirm MySQL dedicated user has restricted privileges to sendy_db only.
Configuring Nginx Rewrite Rules for Sendy
This is where most Apache-to-Nginx migrations stumble. Sendy's bundled .htaccess does nothing on Nginx — there is no equivalent file that the server reads automatically. You translate the routing logic into location blocks inside the server block, or your links break.
The Core Rewrite Directive
The default Sendy routing maps clean URLs to their PHP handlers. Tracking links and unsubscribe endpoints depend on this. The directive that reproduces Apache's behavior is:
rewrite ^/([a-zA-Z0-9-]+)$ /$1.php last;
Place it inside your server block so a request to /l/abc123 resolves to the corresponding PHP file. A subscriber clicking an unsubscribe link should never see a 404.
Caution:
Nginx returning 404 errors on unsubscribe links is almost always a rewrite problem — either the directive is missing, or it sits below a more general location match that swallows the request first. Ordering inside the server block is significant. Put the specific matches above the catch-all.
Locking Down Sensitive Directories
Authorizing SES with SPF
Sendy stores its configuration and internal logic under /includes/. Nothing in there should be reachable from a browser. Deny it outright:
location ~* ^/includes/ { deny all; }
One exception comes later — the bounce webhook lives under that path and needs a carefully scoped allowance. We will handle that in the SNS section rather than punching a hole now.
Database Initialization and Sendy Configuration
A shared server hosting multiple applications is a liability if every service can read every database. I provisioned a dedicated MySQL user scoped entirely to Sendy's own database, so a compromise of the application cannot reach anything else on the host.
Creating the Database and Restricted User
Create the database, then grant privileges that stop at its boundary:
GRANT ALL PRIVILEGES ON sendy_db.* TO 'sendy_user'@'localhost';
The sendy_db.* scope is the important part. The user holds full rights inside that schema and zero visibility into anything outside it. Binding to localhost keeps the connection off the network entirely.
Editing config.php
Sendy reads its settings from includes/config.php. Open it and set three things: the database credentials you just created, the database name, and your installation URL. The URL must match exactly how you access the panel — a trailing-slash mismatch here produces broken links later that are maddening to diagnose.
Running the Setup Script
With config.php in place, visit /install.php in your browser. The script provisions the schema and seeds the application tables. When it finishes, confirm the tables exist by listing them through the MySQL client. If the table count looks right, delete or restrict the installer before going further.
Amazon SES Integration and IAM Security
SES starts every new account in a sandbox. In that state you can only send to verified addresses, which is useless for a marketing list. Lifting the restriction is a support request, and it is reviewed by a human.
Requesting Production Access
The review typically takes 24 to 48 hours. The outcome depends almost entirely on how you fill out the ticket.
Caution:
Sandbox removal requests are frequently denied when the application's bounce handling and subscriber opt-in proof are not spelled out in the support ticket. Describe your double opt-in flow and reference the automated bounce processing you are about to configure. Vague applications get rejected.
An IAM User with Least Privilege
I generated IAM credentials scoped strictly to email sending and notification access. The application has no business touching the rest of your cloud account, so its policy grants only what it actually calls:
- ses:SendRawEmail — the dispatch action Sendy invokes
- sns:ListTopics, reading the notification topics for bounce handling
One nuance worth flagging: the exact policy JSON differs depending on whether you run a single SES region or need cross-region failover. A single-region deployment can hardcode the region in the resource ARN; a failover setup needs broader resource scoping. Decide that before you write the policy, not after.
Storing SMTP Credentials
SES generates SMTP credentials derived from the IAM user. Sendy uses these for outbound mail. Treat them like any other secret — they belong in the configuration, not in version control, and not pasted into a chat where they outlive their usefulness.
Establishing Email Authority with SPF and DKIM
You can send technically valid email all day and still land in spam. Inbox providers at major mailbox operators decide trust through DNS-based authentication, and skipping it is the single most common reason a self-hosted setup underperforms.
Sender Policy Framework tells the world which servers may send on your behalf. Publish a TXT record naming Amazon's infrastructure:
v=spf1 include:amazonses.com ~all
The ~all soft-fail is deliberate during rollout — it flags unauthorized senders without hard-bouncing legitimate mail while you verify the configuration holds.
Cryptographic Signing with DKIM
DKIM attaches a cryptographic signature to each message, proving it left your domain unaltered. SES generates the keys; you publish the corresponding CNAME records it provides. The exact record format and rotation guidance live in the Amazon SES DKIM authentication specifications.
Aligning the MAIL FROM Domain
I configured a custom MAIL FROM domain so the return-path aligns with the sender address rather than defaulting to an Amazon-owned domain. This alignment is what tips DMARC evaluation in your favor and keeps your branding consistent in the technical headers receivers inspect.
Expert Tip:
Budget time for DNS to settle. Propagation runs anywhere from 12 to 36 hours, and SES will not confirm verification until the records resolve globally. Do not start a campaign on a domain still showing pending verification.
Automating Bounce Processing via Amazon SNS
A list that accumulates dead addresses degrades your sender reputation, and reputation is the lever that controls inbox placement. Manual cleanup does not scale past a few sends. The fix is to let Amazon's Simple Notification Service tell Sendy when a message hard-bounces or draws a complaint.
Wiring SNS to the Webhook
Create SNS topics for bounces and complaints, then subscribe Sendy's webhook to them over HTTPS. The endpoint Sendy exposes follows this shape:
https://yourdomain.com/includes/campaigns/bounces.php
This path sits under /includes/ — the directory you denied earlier. This is the single endpoint that needs an exception in your Nginx config. Carve out a precise location match for bounces.php above the broader deny rule so SNS can reach it while the rest of the directory stays sealed.
Testing the Feedback Loop
Amazon provides a mailbox simulator for exactly this. Send a test to the simulator's bounce address and watch Sendy remove the subscriber automatically. If the database updates without your intervention, the loop is closed and list hygiene becomes a background process rather than a chore.
Main Point:
The full chain — Nginx absorbing concurrent load, SES handling transport at a tenth of a cent per thousand, SPF and DKIM earning inbox trust, and SNS keeping the list clean, turns a fragile setup into a system that runs itself. Self-hosted email rewards this kind of configuration discipline; deployments vary by region and provider policy, so validate each authentication record against your own DNS before you scale send volume.









Leave a Comment