Security AWS

addy.io 1.7.3 on AWS User Guide

| Product: addy.io 1.7.3 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the addy.io 1.7.3 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg identity, secrets and PKI listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

addy.io (formerly AnonAddy) is an open source email forwarding service. You give every website, shop and newsletter its own address at your domain, such as shop@admin.example.com or a random x7k2pq9a@example.com. Mail sent to an alias is forwarded to your real mailbox, you can reply through the alias without revealing your address, and an alias that starts receiving spam is switched off with one click. Self hosting it keeps the record of who mails you on a server you control.

The cloudimg image turns the upstream release into a closed, ready to use appliance:

  • HTTPS from the first boot. nginx serves addy.io on port 443 with a certificate generated on your instance's first boot (an end entity certificate for this instance only, never a certificate authority), and port 80 only redirects to HTTPS.
  • No default login, registration closed. Self hosted addy.io ships with public registration switched on and has no concept of an owner, so on a public address anyone could sign up and issue aliases on your domain. This image closes registration before the configuration is first written and creates exactly one administrator with a password generated on the first boot, while both public listeners are still shut.
  • Never an open relay. Postfix on port 25 accepts mail only for your alias domains and relays only for the appliance itself; there is no SMTP AUTH and no submission port.
  • Your own signing keys. Each instance generates its own DKIM key (Rspamd signs forwarded mail with it), its own OpenPGP signing key and its own application secrets. addy-set-domain prints the complete MX, SPF, DKIM and DMARC records for your domain.
  • No database password at all. addy.io reaches MariaDB over the local socket as its own operating system user (MariaDB unix_socket authentication), so there is no database password to leak or rotate. MariaDB and Redis listen on loopback only, Redis with a per instance password.
  • Fail closed. nginx and Postfix refuse to start until first boot has generated every secret, and a guard refuses to let either serve with an empty, reused or published default value or with registration open.
  • Locked down serving. Only the Laravel front controller executes, requests for host names the instance does not serve are dropped, the client address comes from the connection (never a forwarded header), every service is blocked from the instance metadata service and the AMI requires IMDSv2.
  • The official release, verified, built on a fully patched Ubuntu 24.04 LTS with unattended security upgrades switched on.

What is on the image: addy.io 1.7.3 (the official release in /var/www/addy-io, the complete source in /usr/share/addy-io/source), PHP 8.3 FPM, MariaDB 10.11, Redis 7.0, Postfix 3.8, Rspamd 4.2, nginx 1.24, supervisor, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet.
  • A security group that allows TCP 25 (inbound mail for your aliases) from anywhere, TCP 443 (the web interface) and TCP 80 (redirects to HTTPS and certificate challenges) from the networks you use, and TCP 22 (SSH) from your own networks only.
  • A domain you control, for the aliases. You can explore the appliance without one; it starts on a placeholder domain that can never receive real mail.
  • An Elastic IP, so the mail server keeps its address across a stop and start.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick addy.io 1.7.3 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair and a security group as above. The root volume needs at least 16 GiB.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=addy-io}]'

Then allocate an Elastic IP and associate it with the instance (EC2 > Elastic IPs > Allocate, then Associate).

Step 2: Connect to your instance

Version SSH user
addy.io 1.7.3 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot takes about half a minute: it creates the database, generates every secret and key, creates the administrator and only then opens the public listeners. Check that everything is running:

sudo systemctl is-active addy-io-firstboot nginx php8.3-fpm mariadb redis-server rspamd supervisor postfix@-

Expected output:

active
active
active
active
active
active
active
active

The only public listeners are SSH (22), SMTP (25), HTTP (80) and HTTPS (443). MariaDB, Redis and Rspamd listen on loopback only:

sudo ss -lntH | awk '{print $4}' | sort -u

Expected output:

0.0.0.0:22
0.0.0.0:25
0.0.0.0:443
0.0.0.0:80
127.0.0.1:11332
127.0.0.1:11333
127.0.0.1:11334
127.0.0.1:3306
127.0.0.1:6379
127.0.0.53%lo:53
127.0.0.54:53
[::1]:6379
[::]:22
[::]:443
[::]:80

Step 4: Read your per instance credentials

sudo cat /root/addy-io-credentials.txt

Expected output (your values differ; nothing in this file is shared with any other instance):

# addy.io 1.7.3 - generated on first boot by addy-io-firstboot.service
# These credentials are unique to this instance; none was baked into the image. Keep this file secret.

ADDY_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com
ADDY_ADMIN_USERNAME=admin
ADDY_ADMIN_PASSWORD=<ADDY_ADMIN_PASSWORD>
ADDY_ADMIN_RECIPIENT=admin@appliance.example.com
ADDY_EMAIL_DOMAIN=addy-appliance.example.com
ADDY_ALIAS_DOMAIN=admin.addy-appliance.example.com

# This instance's own signing keys (private halves stay on this instance):
ADDY_DKIM_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
ADDY_GPG_SIGNING_KEY_FINGERPRINT=<40 hex characters>

# addy.io reaches MariaDB over the local socket as the OS user addy (MariaDB unix_socket authentication): the
# database account has NO password, so there is none to keep or rotate. On-box maintenance: sudo mariadb
MARIADB_DATABASE=addy_database
MARIADB_USER=addy
# Redis (127.0.0.1:6379) password, also in /var/www/addy-io/.env (REDIS_PASSWORD):
ADDY_REDIS_PASSWORD=<32 characters>

# The web interface is HTTPS only (self-signed certificate minted on this instance; port 80 redirects to it).
# Point the appliance at your own domain:  sudo addy-set-domain <your-domain.com>
# Change only the web name:                sudo addy-io-set-hostname <name>
# EC2 blocks OUTBOUND port 25 by default: forwarded mail needs AWS's port 25 removal or a relay host (see the guide).
# User guide: https://www.cloudimg.co.uk/guides/addy-io-1-7-3-on-ubuntu-24-04-aws/

Step 5: Check the certificate and sign in

The certificate was generated on this instance, so your browser warns that it does not know who issued it. Do not add it to your computer's trust store. Instead, compare the fingerprint your browser shows (click the warning, then View certificate) with the one on the instance:

openssl x509 -in /etc/ssl/addy-io/addy-io.crt -noout -fingerprint -sha256

Expected output (your fingerprint differs):

sha256 Fingerprint=3A:9F:...:C1

If they match, continue to the page, open the address in ADDY_URL and sign in as admin with the password from the credentials file. Step 11 replaces the certificate with one from a public certificate authority once your DNS name points at the instance.

The addy.io sign in page served over HTTPS, asking for a username and password

After signing in you land on the dashboard, which summarises your aliases, recipients, usernames, domains and this month's bandwidth.

The addy.io dashboard with the alias, recipient, username and bandwidth summary

Change the password after your first sign in (Settings, then Security), and turn on two factor authentication on the same page. Once you have your own password you can remove the credentials file with sudo shred -u /root/addy-io-credentials.txt.

Step 6: Why nobody else can claim your appliance

Registration does not exist on this image, so there is nothing to attack:

curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -d 'username=intruder&email=intruder@example.com&password=Intruder12345' \
  https://127.0.0.1/register

Expected output:

404

There is exactly one account, the administrator created on first boot:

sudo mariadb -N -B -e 'SELECT COUNT(*) FROM addy_database.users;'

Expected output:

1

A guard runs before both public listeners (nginx and Postfix) start, and refuses to let either serve if registration is open or any secret is empty, reused or one of the defaults published upstream:

sudo /usr/local/sbin/addy-io-preflight.sh
systemctl show -p ExecStartPre --value nginx.service | grep -o addy-io-preflight
systemctl show -p ExecStartPre --value postfix.service | grep -o addy-io-preflight

Expected output:

addy-io-preflight: OK
addy-io-preflight
addy-io-preflight

Step 7: Create your first aliases

There are two ways to create aliases.

On the fly. Make up any address at your alias domain and hand it out. The alias appears in the interface as soon as it forwards its first email.

Explicitly. Choose Aliases, then Create Alias, pick the domain and a format: random characters, random words, a UUID or a custom local part.

The aliases page listing an alias on the appliance's domain with its forward and reply counters and an active toggle

Aliases can also be created over the API. The block below exchanges your password for an API key (reading the password from the credentials file, so it never appears on a command line) and creates an alias:

PW=$(sudo grep -m1 '^ADDY_ADMIN_PASSWORD=' /root/addy-io-credentials.txt | cut -d= -f2-)
API_KEY=$(jq -n --arg p "$PW" '{username:"admin",password:$p,device_name:"cli"}' \
  | curl -sk -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' \
      --data-binary @- https://127.0.0.1/api/auth/login | jq -r .api_key)
unset PW
curl -sk -X POST -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
  -H 'Accept: application/json' -d '{"domain":"admin.addy-appliance.example.com"}' \
  https://127.0.0.1/api/v1/aliases | jq '.data|{email,active}'

Expected output (your alias differs):

{
  "email": "k3v8qz2m@admin.addy-appliance.example.com",
  "active": true
}

The sign in endpoint allows three requests per minute, so space out repeated calls. Keep the API key: the addy.io browser extensions and mobile apps use the same key.

Step 8: Point your own domain at the appliance

Until you do this, the appliance uses the placeholder domain addy-appliance.example.com and cannot receive real mail. One command repoints the application, Postfix and the DKIM signing tables together, generates a 2048 bit DKIM key for your domain, serves the web interface as your domain with a new certificate, and prints the records to publish:

sudo addy-set-domain example.net

Output (the public IP and the DKIM key are your instance's own):

==> repointing this appliance at example.net (mail host mail.example.net, web interface https://example.net/)
addy-io-set-hostname: addy.io is served at https://example.net/

==========================================================================
 Publish these DNS records for example.net
==========================================================================
  A      mail.example.net          <public-ip>
  A      example.net           <public-ip>
  A      *.example.net          <public-ip>
  MX     example.net            mail.example.net   (priority 10)
  MX     *.example.net          mail.example.net   (priority 10)
  TXT    example.net            "v=spf1 mx -all"
  TXT    *.example.net          "v=spf1 mx -all"
  TXT    _dmarc.example.net     "v=DMARC1; p=quarantine; adkim=s"
  TXT    default._domainkey.example.net
         "v=DKIM1; k=rsa; p=<this instance public key>"

  Use an Elastic IP so the address survives a stop/start, and ask AWS to set the
  reverse DNS (PTR) of that Elastic IP to mail.example.net.

  EC2 BLOCKS OUTBOUND PORT 25 BY DEFAULT. Forwarded mail will not leave this
  instance until you either ask AWS to remove the port 25 restriction (the
  "Request to remove email sending limitations" form) or relay through a smart
  host such as Amazon SES on port 587. See the user guide.
==========================================================================

Use --hostname mx1.example.net if your mail host is not mail.<domain>, and --web-host addy.example.net to serve the web interface under a different name.

What each record does:

  • A records point the mail host, the web interface and every username subdomain (*.example.net, so anything@admin.example.net works) at your Elastic IP.
  • MX records tell other mail servers to deliver mail for your domain and its subdomains to this instance.
  • SPF (v=spf1 mx -all) says only your MX host sends mail for the domain. If you relay through Amazon SES (Step 10), add its include, for example "v=spf1 mx include:amazonses.com -all".
  • DKIM publishes the public half of this instance's signing key. The record is printed on one line and is longer than 255 characters: Route 53 and most DNS consoles accept it pasted as is, some need it split into several quoted strings of up to 255 characters each.
  • DMARC (p=quarantine; adkim=s) asks receivers to quarantine mail for your domain that fails strict DKIM alignment. Start with p=none if you prefer to watch reports first, and move to p=reject once mail is flowing.

Two more things are needed outside the instance:

  1. Reverse DNS. Ask AWS to set the PTR record of your Elastic IP to your mail host (EC2 > Elastic IPs > Actions > Update reverse DNS). Many receiving servers reject mail from an address with no matching PTR record.
  2. Inbound port 25 open to the internet in the security group.

Postfix decides which subdomains it accepts by asking the addy.io database (through Postfix's own socket authenticated account). Check a username subdomain resolves:

sudo -u postfix postmap -q admin.addy-appliance.example.com \
  mysql:/etc/postfix/mysql-virtual-alias-domains-and-subdomains.cf

Expected output:

1

Step 9: Recipients, the mailboxes your aliases forward to

A recipient is a real mailbox that aliases forward to. The appliance starts with the placeholder recipient admin@appliance.example.com; add your own address under Recipients, then Add Recipient. addy.io sends a verification email to it, which needs outbound mail working (Step 10).

The recipients page, where the mailboxes forwarded mail goes to are added and verified

Once a recipient is verified, make it the default, point aliases at it, and add its PGP public key if you want forwards encrypted to you.

Step 10: Outbound mail and EC2's port 25 restriction

Amazon EC2 restricts outbound TCP 25 on new accounts. Inbound mail to your aliases still arrives, but the forward cannot leave. Either ask AWS to lift the restriction (the Request to remove email sending limitations form, which also asks for your reverse DNS), or relay through a smart host such as Amazon SES on port 587:

sudo postconf -e 'relayhost = [email-smtp.<region>.amazonaws.com]:587'
sudo postconf -e 'smtp_sasl_auth_enable = yes'
sudo postconf -e 'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd'
sudo postconf -e 'smtp_sasl_security_options = noanonymous'
sudo postconf -e 'smtp_tls_security_level = encrypt'
sudo install -m 600 /dev/null /etc/postfix/sasl_passwd
sudo nano /etc/postfix/sasl_passwd     # one line: [email-smtp.<region>.amazonaws.com]:587 <relay-user>:<relay-password>
sudo postmap /etc/postfix/sasl_passwd
sudo systemctl restart postfix

Watch messages leave with sudo tail -f /var/log/mail.log. The relay settings are client settings only: the server still refuses to relay for anyone else, which you can check at any time:

postconf -h mynetworks smtpd_relay_restrictions smtpd_sasl_auth_enable

Expected output:

127.0.0.0/8 [::1]/128
permit_mynetworks, reject_unauth_destination
no

From any other host, a message to an outside domain is refused with 554 5.7.1 Relay access denied.

Public DNS blocklists are deliberately off. Spamhaus refuses queries made through cloud providers' resolvers, which would turn a spam check into lost mail. Rspamd still checks SPF, DKIM and DMARC and runs its Bayesian filter. If you subscribe to Spamhaus DQS, add your key to smtpd_recipient_restrictions in /etc/postfix/main.cf.

Step 11: Use a trusted certificate

Once your DNS name points at the instance, replace the self generated certificate with one from Let's Encrypt. Port 80 already serves the challenge directory /var/www/acme-challenge, and nginx and Postfix both read /etc/ssl/addy-io/addy-io.crt and .key:

sudo apt-get update && sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/acme-challenge -d example.net -d mail.example.net
sudo install -m 644 /etc/letsencrypt/live/example.net/fullchain.pem /etc/ssl/addy-io/addy-io.crt
sudo install -m 600 /etc/letsencrypt/live/example.net/privkey.pem /etc/ssl/addy-io/addy-io.key
sudo systemctl reload nginx postfix

To keep renewals in place, add the two install lines and the reload as a certbot deploy hook (/etc/letsencrypt/renewal-hooks/deploy/addy-io.sh).

Step 12: The complete source is on the machine

addy.io is licensed under the GNU Affero General Public License v3 or later. The complete corresponding source for the exact version running here ships on the image with its checksum, the licence text and a written offer:

ls /usr/share/addy-io/source
cd /usr/share/addy-io/source && sha256sum -c addy-io-1.7.3-source.tar.gz.sha256

Expected output:

LICENSE
WRITTEN-OFFER.txt
addy-io-1.7.3-source.tar.gz
addy-io-1.7.3-source.tar.gz.sha256
addy-io-1.7.3-source.tar.gz: OK

Step 13: Where your data lives and how to back it up

Path Contents
/var/www/addy-io The application, its configuration (.env, readable by root and the addy service user only) and the compiled frontend
/var/lib/mysql MariaDB: your aliases, recipients, domains, rules and forwarding history
/var/lib/redis Redis: sessions, cache and the delivery queue
/var/lib/rspamd/dkim This instance's DKIM private keys
/var/lib/addy-io/gnupg This instance's OpenPGP signing key
/etc/postfix Mail server configuration
/root/addy-io-credentials.txt The credentials written at first boot

Back up the database and the keys together; restoring one without the other leaves signed mail that cannot be verified:

sudo mariadb-dump --single-transaction addy_database | gzip > ~/addy-io-$(date +%F).sql.gz
sudo tar czf ~/addy-io-keys-$(date +%F).tar.gz /var/lib/rspamd/dkim /var/lib/addy-io/gnupg /var/www/addy-io/.env

Copy both files off the instance (and keep the second one private), or take EBS snapshots of the volume.

Step 14: Day to day operation

Two queue workers run under supervisor and process forwarding:

sudo supervisorctl status

Expected output:

addy-io:addy-io_00               RUNNING   pid 1443, uptime 0:04:06
addy-io:addy-io_01               RUNNING   pid 1444, uptime 0:04:06

Every service is blocked from the instance metadata service; for example PHP:

systemctl show -p IPAddressDeny --value php8.3-fpm

Expected output:

::ffff:169.254.169.254/128 169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32

Useful commands:

  • Restart the application after a configuration change: sudo systemctl restart php8.3-fpm supervisor nginx
  • Watch the mail flow: sudo tail -f /var/log/mail.log
  • Watch the application log: sudo tail -f /var/www/addy-io/storage/logs/laravel.log
  • Open the Rspamd web interface over an SSH tunnel: ssh -L 11334:127.0.0.1:11334 ubuntu@<public-ip>, then browse to http://127.0.0.1:11334/

Ubuntu security updates are applied automatically by unattended-upgrades. To update by hand: sudo apt-get update && sudo apt-get -y dist-upgrade.

What cloudimg changed

  • Registration closed and one administrator created on first boot with a per instance password (passed to the application on standard input, never on a command line); upstream's published default secrets are refused by a start guard.
  • Per instance APP_KEY, Redis password, application secrets, DKIM key, OpenPGP signing key and TLS key, generated on first boot; none is in the image.
  • MariaDB with no database password (socket authentication for the addy and postfix accounts), a database directory created fresh on first boot from a credential free schema, MariaDB and Redis on loopback only.
  • HTTPS with a per instance end entity certificate, port 80 redirecting, unknown host names dropped, forwarded headers ignored, the 2048 bit DKIM record printed in full.
  • Every service unit denied the instance metadata service; IMDSv2 required.
  • SSH: key only, no root login, no X11 forwarding. The firmware update timer (fwupd-refresh) is masked, because EC2 instances have no firmware it can update. The stock snakeoil certificate generator (ssl-cert.service) is masked too: nothing on the image uses that certificate.
  • No build tools on the image (Composer and Node.js were used for the build and removed); PHP is the Ubuntu build that carries the October 2026 PHP fixes. ImageMagick is not installed (addy.io does not need it).

Security notes

  • Open advisories at build time (28 October 2026): none against the PHP dependencies (composer audit of both lock files), none against the shipped frontend. The upstream lock lists vue-template-compiler (GHSA-g3ch-rx76-35fx, moderate), which is never imported and is not in the compiled frontend; the build tools were removed.
  • Restrict TCP 22, 80 and 443 to your own address ranges in the security group; only 25 needs to be open to everyone.
  • Keep registration closed. The start guard refuses to run either public listener if ANONADDY_ENABLE_REGISTRATION is anything but false.

Troubleshooting

The web interface does not answer. First boot may still be running. Check test -f /var/lib/cloudimg/addy-io-firstboot.done && echo done, then sudo journalctl -u addy-io-firstboot -n 50. nginx does not start until first boot has finished, by design.

nginx or Postfix refuse to start. Run sudo /usr/local/sbin/addy-io-preflight.sh to see which check failed.

Aliases receive no mail. Check the MX record points at your mail host, inbound 25 is open in the security group, the postmap check in Step 8 returns 1 for your subdomain, and read /var/log/mail.log.

Forwards are not delivered. Outbound port 25 is restricted on EC2: see Step 10. Check the queue with sudo postqueue -p.

Mail lands in spam. Publish the SPF, DKIM and DMARC records printed by addy-set-domain and set reverse DNS on the Elastic IP.

Support

cloudimg provides support for this image by email at support@cloudimg.co.uk. See the identity, secrets and PKI listing guide for the other versions in this listing.