Skip to content
In this article14 sections

Sendy is a self-hosted newsletter and campaign tool. It keeps your subscribers and templates on your own server, and it sends through Amazon SES, which is built for bulk mail and charges per message rather than per subscriber. Many small senders find that combination cheaper than a hosted list service, but it also means you run the parts a hosted service would normally handle: the web server, the database, the cron job, the bounce handling and the sending reputation.

This guide follows the order in which the pieces must work. You will install the web stack on Ubuntu 24.04, install Sendy, verify your domain in SES, connect the two, handle bounces and complaints, schedule campaigns and test delivery. Each section ends with a check, so you know the step worked before you move on. Where Sendy’s documentation and community reports differ, the guide says so and shows how to confirm the right answer on your own install.

What you need before you start. A server running Ubuntu 24.04 with a sudo user, a subdomain such as mail.example.com pointing to the server’s IP address, a paid Sendy license, an AWS account with SES access, and access to your domain’s DNS. Sendy’s pages and tracking links use the subdomain, so it must serve HTTPS.

Part 1: Prepare the server

Check the firewall

Allow web traffic and SSH, and nothing else you do not use. If UFW is already enabled, add the two rules and check the result:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw status verbose

Expected: ports 80 and 443 listed as ALLOW. If you have not enabled UFW yet, do the SSH rule first and keep your current session open until you have confirmed a second login works.

Install Nginx, MariaDB and PHP

Ubuntu 24.04 ships PHP 8.3 in its default repositories. Install the web server, the database, PHP-FPM and the extensions Sendy checks for:

sudo apt update
sudo apt install nginx mariadb-server php8.3-fpm php8.3-mysql php8.3-curl php8.3-mbstring php8.3-xml php8.3-gd unzip certbot python3-certbot-nginx

These packages provide mysqli (through php8.3-mysql), curl, mbstring, SimpleXML (through php8.3-xml) and GD. Sendy’s compatibility check, in the next part, names anything else it needs. If it reports gettext or another extension as missing, install the matching php8.3- package and run the check again.

Make sure the command-line PHP has the same extensions

This is the most common cause of a Sendy scheduled campaign that never sends. The web server runs PHP through PHP-FPM, but cron runs the command-line PHP. Both must load mysqli. Check the command-line build:

php -v
php -m | grep -i -E "mysqli|curl|gettext|simplexml"

Expected: the four names printed. If one is missing, install its package for the same PHP version and check again. If you have several PHP versions installed, make sure the php command points to 8.3, with update-alternatives --display php.

Check the PHP-FPM service

sudo systemctl status php8.3-fpm --no-pager
ls -l /run/php/php8.3-fpm.sock

Expected: the service active, and the socket file listed. Nginx will pass PHP requests to this socket.

Part 2: Create the database

Run MariaDB’s secure setup first, then create a database and a user that can reach only that database:

sudo mysql_secure_installation
sudo mysql
CREATE DATABASE sendy CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'sendy'@'localhost' IDENTIFIED BY 'use-a-long-random-password';
GRANT ALL PRIVILEGES ON sendy.* TO 'sendy'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Store the password in your password manager. You will enter it in one place in Sendy’s configuration, and you will not need it again unless you rebuild the server.

Check that the new user can log in and reach the database:

mysql -u sendy -p -h localhost sendy -e "SELECT 1;"

Expected: a table with the value 1. If you see an access error, the password you typed does not match the one in the CREATE USER statement.

Part 3: Install Sendy

Put the files in place

Unpack the Sendy archive you downloaded after purchase. Keep the files outside the web root until they are in the right place, then copy them into /var/www/sendy:

sudo mkdir -p /var/www/sendy
sudo unzip ~/sendy-archive.zip -d /tmp/sendy-extract
sudo cp -a /tmp/sendy-extract/sendy/. /var/www/sendy/
sudo chown -R www-data:www-data /var/www/sendy
sudo find /var/www/sendy -type d -exec chmod 755 {} \;
sudo find /var/www/sendy -type f -exec chmod 644 {} \;

The archive folder name can differ between Sendy versions. If the sendy folder is not inside the archive, copy from the folder that contains includes/ and index.php.

Set the uploads folder permissions

Sendy’s installation guide asks for write permission on the uploads folder for images, attachments and CSV imports. The guide suggests setting it to 777, which lets any user on the server write there. Give only the web server write access instead:

sudo chown -R www-data:www-data /var/www/sendy/uploads
sudo chmod -R 755 /var/www/sendy/uploads

Owning the folder as www-data already lets PHP write there. If an upload still fails after this, check the error log in Part 4 before you widen the permissions.

Edit the configuration file

sudo nano /var/www/sendy/includes/config.php

Set the application path to the public address of the install, https://mail.example.com, and set the database host, database name, user and password to the values from Part 2. Use the variable names already present in the file, and change only their values. The file contains the database password, so never copy it into a public repository or a shared document.

Check the values were saved, without printing the password to your screen:

sudo grep -E "APP_PATH|DB_HOST|DB_NAME|DB_USER" /var/www/sendy/includes/config.php

Configure Nginx and HTTPS

Create a server block for the subdomain. The rules pass every PHP request to PHP-FPM and send everything else to the file system:

server {
    listen 80;
    server_name mail.example.com;
    root /var/www/sendy;
    index index.php;

    client_max_body_size 20M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\.ht {
        deny all;
    }
}

Save it as /etc/nginx/sites-available/sendy, enable it, and test before reloading:

sudo ln -s /etc/nginx/sites-available/sendy /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Issue the certificate, which also adds the HTTPS block:

sudo certbot --nginx -d mail.example.com

Certbot should report that the certificate was issued and that the renewal timer is active. Confirm the renewal works without changing anything:

sudo certbot renew --dry-run

Run Sendy’s compatibility check

Sendy provides a check page that reports missing PHP extensions and configuration problems. Open it once in your browser:

https://mail.example.com/_compatibility.php?i=1

Every item should be green. Sendy’s own guidance says a yellow mod_rewrite item does not stop the install. This guide uses Nginx, where the try_files rule above does the job mod_rewrite does on Apache. Once the install is complete and the checks pass, remove the file so it is not left public:

sudo rm /var/www/sendy/_compatibility.php

Removing it is a recommendation from this guide, not a step Sendy’s documentation requires. You can always recreate the file from the Sendy package if you need it again.

Finish the installation in the browser

Open https://mail.example.com. The installer asks for your license key, the admin email address and a password, and it can take the AWS access key ID and secret now or later. Enter the license key from your purchase email. Choose a strong admin password that you do not use anywhere else. Click install and wait for the confirmation.

After installation, sign in and open the settings page to confirm the application path and the database connection are working. A database error here means the values in config.php do not match Part 2.

Warning. Do not block the subscribe, unsubscribe or tracking URLs at the web server. Subscribers, email clients and the Amazon bounce service all reach those paths from outside, and blocking them breaks unsubscribes. Unsubscribing is a legal requirement for bulk email in most places. Protect the admin login with a strong password and rely on fail2ban for repeated attempts. The Ubuntu VPS security checklist covers fail2ban setup.

Part 4: Know where the logs are

Keep these commands ready before you start the SES work, because almost every later problem shows up in one of these logs:

sudo tail -n 50 /var/log/nginx/error.log
sudo tail -n 50 /var/log/php8.3-fpm.log
sudo journalctl -u php8.3-fpm --no-pager -n 50
sudo journalctl -u cron --no-pager -n 50

The Nginx log shows web requests that failed. The PHP-FPM logs show PHP errors such as a missing extension or a permission problem. The cron log shows whether the scheduled job ran at all.

Part 5: Verify your domain in Amazon SES

SES signs your mail and sends it on your behalf, so it must be allowed to send for your domain. Use the domain, not a single address, because a domain identity covers every address on it.

Create the identity

In the SES console, choose the region you want to send from and keep it the same for every later step. Create an identity for example.com, and turn on Easy DKIM with the RSA 2048-bit key. SES then gives you three CNAME records to publish.

Publish the DKIM records

Each record maps a token to a name under _domainkey, and points to Amazon’s DKIM host. The records have this form, where abc123 stands for the token SES gives you:

abc123._domainkey.example.com.   IN CNAME abc123.dkim.amazonses.com.

Publish all three exactly as SES shows them. Copy the tokens from the SES console, not from this example. DKIM stays pending until SES can see the records in DNS, and then it changes to Success. DNS changes can take time to spread, so wait before you judge a failure.

Set the SPF record

SES needs your domain’s SPF record to include Amazon’s sending servers. A domain may have only one SPF record. If you already have one, add the include to it, rather than publishing a second record, because two SPF records make the check fail:

example.com.   IN TXT "v=spf1 include:amazonses.com ~all"

If the domain already sends from another service, keep that service’s include in the same record, for example v=spf1 include:amazonses.com include:_spf.google.com ~all.

Check the records from the server

dig TXT example.com +short
dig CNAME abc123._domainkey.example.com +short

Expected: the SPF text including amazonses.com, and a CNAME that ends in dkim.amazonses.com. Repeat the DKIM check for all three tokens.

Add DMARC in monitoring mode

DMARC tells receivers what to do when SPF and DKIM fail, and it sends you reports. Start with monitoring only:

_dmarc.example.com.   IN TXT "v=DMARC1; p=none; rua=mailto:[email protected]"

Read the reports for a few weeks before you tighten the policy. The full method is in how to set up DMARC without losing email.

Part 6: Create an IAM user that can only send

Do not give Sendy your root account credentials, and do not give it an administrator user. Create a separate IAM user for Sendy with no console access and a policy that grants only what sending needs.

The permissions

The SES actions in the AWS service reference that matter for sending are ses:SendEmail and ses:SendRawEmail, plus the read actions for quota and statistics, ses:GetSendQuota and ses:GetSendStatistics. SES’s access-control documentation notes that a user connecting over SMTP needs at least ses:SendRawEmail. Sendy’s own AWS setup video shows the exact policy it expects, so use that as the final reference. The policy below is a least-privilege starting point:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SendMail",
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    },
    {
      "Sid": "ReadSendingLimits",
      "Effect": "Allow",
      "Action": ["ses:GetSendQuota", "ses:GetSendStatistics"],
      "Resource": "*"
    }
  ]
}

Attach the policy to the Sendy user, generate an access key pair for it, and store the secret in your password manager at once, because AWS shows it only once.

If Sendy reports AccessDenied later, the message names the missing action. Add that one action to the policy rather than attaching a broader managed policy.

Rotate keys on a schedule

Create a new access key, update Sendy, send a test email, then deactivate and delete the old key. Do this at least yearly, and immediately if you suspect the key has leaked.

Part 7: Connect Sendy to SES

In Sendy, open the Amazon Web Services settings. Enter the access key ID, the secret access key and the region, the same region as the SES identity. Set the from name and the from email address. The from address must be on the verified domain, and it must match exactly, including the letter case, because SES compares it character for character.

Send one test email to an address you control. If you see an error, read it in the Sendy interface and then in the PHP-FPM log from Part 4. Common results are:

  • Email address not verified. The from address is not on the verified identity, or the case differs.
  • AccessDenied. The IAM policy is missing an action. The error names it.
  • Region errors. The region in Sendy differs from the region where the identity was created.

Part 8: Leave the SES sandbox

A new SES account starts in the sandbox. There, SES sends only to addresses you have verified, and it limits how many messages you can send. A campaign to a real list will not reach most subscribers until production access is approved.

In the SES console, open the account dashboard and request production access. Describe the real process: how people subscribe and confirm, how unsubscribes are handled, how you handle bounces and complaints, and what kind of mail you send. Be specific and accurate. A request that describes a consent process you actually run is the one that gets approved.

After approval, check the new sending quota and rate in SES before you send a large campaign, and start with a smaller segment.

Part 9: Handle bounces and complaints

SES reports bounces and complaints through Amazon SNS. Sendy uses those notifications to stop mailing addresses that bounce and to remove people who complain. If this is not connected, a list with dead addresses can damage your sending reputation quickly, and SES may pause your account.

Create the SNS topics and subscriptions

In the SNS console, in the same region as SES, create two standard topics, one for bounces and one for complaints. For each topic, create an HTTPS subscription that points to Sendy’s bounce endpoint. Community reports and Sendy support replies give this path:

https://mail.example.com/sendy/includes/campaigns/bounces.php

If Sendy is installed at the root of the subdomain, as in this guide, drop the /sendy prefix. Use the path the Sendy settings page shows for your install, and use the complaints endpoint from the same page for the complaints topic. The endpoint path is the one most often set wrong, so compare it with the settings page rather than with this example.

Confirm each subscription. SNS sends a confirmation request to the endpoint, and an unconfirmed subscription receives nothing. Confirm both subscriptions in the SNS console, and check their status shows Confirmed.

In the SES identity settings for your domain, set the bounce and complaint notifications to the matching SNS topics. Turn on the option to include the original headers, if Sendy’s settings page asks for it.

Test with the simulator

Amazon provides mailbox simulator addresses that generate bounces and complaints on demand, without sending mail to a real person. Send a message to [email protected] and then to [email protected]. Check that each one shows up in Sendy’s bounce or complaint record. A subscription that is confirmed but never records a simulated bounce is not working, and the cause is almost always the endpoint path or the subscription status.

Do not expect a real address that bounces to appear in Sendy the moment it fails. SES reports the bounce when it occurs, and a soft bounce can be retried before it becomes a hard one.

Part 10: Schedule campaigns with cron

Sendy does not send campaigns in the background on its own. A scheduled job has to call its scripts. Sendy’s scripts are run by cron, and community guidance and Sendy support both describe running the scheduled-campaign script every few minutes, and the autoresponder script more often, because autoresponders send on a shorter cycle. Check the interval against your Sendy version’s settings or documentation, and set the schedule below as the starting point:

sudo crontab -u www-data -e
*/5 * * * * curl -s https://mail.example.com/scheduled.php > /dev/null 2>&1
* * * * * curl -s https://mail.example.com/autoresponders.php > /dev/null 2>&1

The first line runs scheduled campaigns every five minutes. The second runs autoresponders every minute. Run each script once by hand first, so you can see its output before cron calls it repeatedly:

curl -s https://mail.example.com/scheduled.php

Expected: no error output from the script. A blank page is normal for a script that has nothing to send. A PHP error means the job will fail in cron too, so fix it first.

Some community reports describe running the script with the command-line PHP binary instead of curl. Both approaches depend on the same extensions, so the check in Part 1 applies to either. Use one approach, not both, or each campaign may send twice.

Confirm the job runs

Schedule a campaign for about ten minutes ahead, to a test list. Wait past the scheduled time, then check the campaign status in Sendy and the cron output. If the status does not change, check sudo journalctl -u cron --no-pager -n 50 for the job, and the Nginx and PHP-FPM logs from Part 4 for the request.

A common cause of silent failure is the scheduled campaign being queued but the cron job running as the wrong user, so it cannot read the configuration. Running the job as www-data, as above, avoids that.

Part 11: Test deliverability before the first real campaign

Send a test campaign to a few inboxes you control at large providers. Open the message headers in each one and check three results: spf=pass, dkim=pass, and dmarc=pass. The sending domain shown in the headers must match your verified domain. If any check fails, fix the DNS record it names before you send to a real list.

Then check the content and the unsubscribe link. Click the unsubscribe link in a test message and confirm that the address is removed and that it is not mailed again. A broken unsubscribe causes complaints, and complaints lower your reputation faster than almost any other problem.

Common problems and what to check

  • Campaigns stay in “scheduled” and never send. Check the cron job from Part 10 and the command-line PHP extensions from Part 1.
  • The admin page loads but campaigns fail with a database error. The credentials in config.php do not match the database user from Part 2.
  • Uploads fail. The uploads folder is not writable by www-data. Check its owner with ls -ld /var/www/sendy/uploads.
  • SES rejects the sender. The from address is not on the verified domain, or its letter case differs from the identity.
  • Bounces never appear. The SNS endpoint path is wrong, or the subscription is not confirmed. Use the simulator from Part 9 to confirm the path.
  • Mail goes to spam. One of SPF, DKIM or DMARC is failing, or the list contains old or unengaged addresses. Check the headers from Part 11 first.
  • Sending stops after a day or two. The account may still be in the sandbox, or SES paused sending because of high bounce or complaint rates. Check the account status in SES before you change anything else.

Frequently asked questions

Does Sendy send the email itself or through SES?

Through SES, once you connect it in the settings. Sendy manages the list, the templates, the scheduling and the tracking. SES does the delivery.

Can I use SES without Sendy?

Yes. SES works with any application that can send through its API or SMTP interface. Sendy adds subscriber management, forms, campaigns and reporting on top.

Why do I need a separate subdomain?

Sendy’s tracking and unsubscribe links point to the application’s address. A dedicated subdomain keeps those links separate from your main website, and it lets you manage its certificate and firewall rules on its own.

Should I run a mail server on the same machine?

Not for campaign sending. Sendy sends through SES, so the server does not need an SMTP service of its own. A local mail server on a small VPS often has a poor reputation. If you also want to host personal mailboxes, read how to host your own email server first.

How many emails can I send?

That depends on your SES sending limits, which rise as your sending history builds. Check the limits in SES after production access is approved, and increase volume gradually.

Sending rules depend on where your recipients live. Most jurisdictions require consent or a legitimate basis, a way to unsubscribe, and an accurate sender identity. This guide covers the technical setup only. Check the rules that apply to your list before you send.

Summary

A working Sendy and SES setup depends on the same things as any reliable mail system: a verified domain with correct DNS records, a sending-only IAM user, bounce and complaint handling that you have tested with the simulator, a cron job that runs under the right user, and an unsubscribe link that works. Set those up in order, test each one with a real message, and move out of the SES sandbox before you send to a real list.

Share this guide