Data Encryption for Self-Hosted Platforms

A self-hosted AcelleMail install handles personally-identifiable subscriber data, sometimes regulated data (EU GDPR), and business-critical campaign content. Encryption is the baseline defense if any layer is compromised. This guide walks the 4 layers: TLS in transit (the easy one), MySQL encryption at rest (InnoDB tablespace), filesystem encryption for backups (gpg/LUKS), and Laravel-level encryption for sensitive fields (consent records, API tokens).

What this is for

A self-hosted AcelleMail install handles:

  • Personally-identifiable subscriber data — email addresses, names, custom fields
  • Sometimes regulated data — GDPR subjects' personal data, possibly HIPAA-relevant in healthcare contexts
  • Business-critical campaign content — templates, sending lists, automation logic
  • Credentials — sending-provider API keys, your own API tokens, customer billing info

Encryption is the baseline defense if any layer is compromised — a stolen backup, a leaked disk image, a database dump found in a forgotten S3 bucket. This guide walks the 4 layers you should encrypt + how.

This isn't compliance theater — each layer protects against specific real attack scenarios.

Layer 1 — TLS in transit (the easy one)

Protects against: network interception (man-in-the-middle, ISP snooping, Wi-Fi sniffing)

What: every HTTP request/response is encrypted with TLS. Visitor to admin, admin to API, AcelleMail to sending provider, all over TLS.

How:

# Install certbot + nginx plugin
sudo apt install -y certbot python3-certbot-nginx

# Provision cert via Let's Encrypt
sudo certbot --nginx -d mail.example.com \
  --non-interactive --agree-tos --email you@example.com --redirect

Verify your nginx config has the right protocols (TLS 1.2 + 1.3 only):

server {
    listen 443 ssl http2;
    server_name mail.example.com;

    ssl_certificate     /etc/letsencrypt/live/mail.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;

    # Modern, secure cipher suite
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;

    # HSTS — instructs browsers to refuse plain HTTP for 1 year
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

# 80 → 443 redirect (certbot --redirect adds this automatically)
server {
    listen 80;
    server_name mail.example.com;
    return 301 https://$server_name$request_uri;
}

Verify with https://www.ssllabs.com/ssltest/analyze.html?d=mail.example.com — target A or A+ grade.

Auto-renewal: certbot's systemd timer renews ~30 days before expiry. Verify it's running:

sudo systemctl status certbot-renew.timer    # Rocky
sudo systemctl status certbot.timer          # Ubuntu/Debian (or check /etc/cron.d/certbot)

See Install AcelleMail on Ubuntu 24.04 LTS Step 7 for the full install context.

Layer 2 — MySQL encryption at rest

Protects against: stolen disk image / lost backup files / casual snooping by a sysadmin who shouldn't be looking

What: InnoDB tablespace encryption — the database files on disk are encrypted with a key managed by MySQL.

How — first enable the keyring plugin (in /etc/mysql/mysql.conf.d/mysqld.cnf):

[mysqld]
early-plugin-load = keyring_file.so
keyring_file_data = /var/lib/mysql-keyring/keyring

Then create the keyring directory + restart:

sudo mkdir -p /var/lib/mysql-keyring
sudo chown mysql:mysql /var/lib/mysql-keyring
sudo chmod 750 /var/lib/mysql-keyring
sudo systemctl restart mysql

Now encrypt tables that hold PII:

ALTER TABLE subscribers ENCRYPTION='Y';
ALTER TABLE contacts ENCRYPTION='Y';
ALTER TABLE customers ENCRYPTION='Y';
ALTER TABLE email_log ENCRYPTION='Y';
-- Test encryption works:
SHOW CREATE TABLE subscribers\G   -- should show ENCRYPTION='Y'

Requires MySQL 5.7.11+ or MariaDB 10.1+. For all-new tables, set default_table_encryption = ON in mysqld.cnf so they're encrypted at creation.

Key management — the keyring_file plugin stores the master key in a file on the same server. For more-secure setups (regulated industries), use AWS KMS / HashiCorp Vault via their keyring plugins.

Performance impact: ~3-5% CPU overhead for InnoDB tablespace encryption. Negligible for most AcelleMail workloads.

Layer 3 — Filesystem encryption for backups

Protects against: lost / stolen backup files, including offsite copies in S3 / B2 / Wasabi

What: every backup file is encrypted at rest. Encryption happens during the backup process, before the file leaves your server.

Option A — gpg encryption per file

# Daily backup script — encrypt MySQL dump with gpg
mysqldump --single-transaction --routines acellemail | gzip | \
  gpg --symmetric --cipher-algo AES256 --batch --passphrase-file /root/.backup-pass \
      -o /var/backups/acellemail-$(date +%F).sql.gz.gpg

/root/.backup-pass contains the passphrase (chmod 0400). Store the passphrase ALSO in your password manager — without it the backup is unrecoverable.

Decryption (during restore):

gpg --decrypt --batch --passphrase-file /root/.backup-pass /var/backups/acellemail-2026-05-16.sql.gz.gpg | gunzip | mysql acellemail

Option B — restic (content-addressed + encrypted)

restic handles encryption + incremental + offsite in one tool:

# One-time init
sudo restic -r s3:s3.amazonaws.com/your-bucket/acellemail init
# (prompts for repo password — STORE IT)

# Daily backup
sudo restic -r s3:s3.amazonaws.com/your-bucket/acellemail backup /var/backups/acellemail

# Restore
sudo restic -r s3:s3.amazonaws.com/your-bucket/acellemail restore latest --target /tmp/restore

Restic encrypts with the repo password client-side; even the cloud provider can't see the contents.

Option C — full-disk LUKS

For encrypting the entire filesystem of the AcelleMail server (not just backups), use LUKS:

# At install time only — LUKS encryption of the data partition
sudo cryptsetup luksFormat /dev/sda2
sudo cryptsetup luksOpen /dev/sda2 acelle-data
sudo mkfs.ext4 /dev/mapper/acelle-data
sudo mount /dev/mapper/acelle-data /var/www/acellemail

Pros: comprehensive. Cons: server can't auto-boot without the passphrase (or stored key) — operationally complex.

For most AcelleMail installs, Option B (restic) is the right answer — handles backup + encryption + offsite in one tool. See Automated Database Backups for the full backup strategy.

Layer 4 — Application-level encryption (Laravel encrypt/decrypt)

Protects against: the database being compromised (or dumped, or accessed by a rogue DBA) while application secrets are still safe

What: specific sensitive fields are encrypted by AcelleMail's code before being stored in MySQL. Even with raw DB access, the field values are ciphertext.

Use cases:

  • GDPR consent records (legal-grade evidence; encrypt for tamper resistance)
  • API tokens you store on behalf of customers
  • Sensitive custom subscriber fields (PHI in healthcare, financial info)

How — Laravel ships encrypt() and decrypt() helpers. In a custom extension (see extending source code):

// Storing
$subscriber->consent_text = encrypt($consentText);
$subscriber->save();

// Retrieving
$plainText = decrypt($subscriber->consent_text);

For models, use the encrypted cast (cleaner — automatic encrypt/decrypt):

// In your model
protected $casts = [
    'consent_text' => 'encrypted',
    'sensitive_field' => 'encrypted',
];

Now $subscriber->consent_text = $plain; is auto-encrypted on save; $subscriber->consent_text is auto-decrypted on read.

The APP_KEY discipline

Laravel's encryption uses APP_KEY from .env as the master key. Protect it like a password manager protects a vault key:

  • Never commit .env to git
  • File permission 0640 owner=www-data (per hardening checklist Control 15)
  • Back up .env SEPARATELY (to your password manager / secrets vault, NOT in your regular file backups — restoring an old backup with the wrong APP_KEY can't decrypt fields encrypted with the new key)

Key rotation

For high-security installs, rotate APP_KEY annually:

  1. Generate new key: php artisan key:generate --show (don't apply yet)
  2. For each encrypted field, decrypt with old key + re-encrypt with new key (custom artisan command; iterate models)
  3. Once re-encryption is done, swap APP_KEY in .env
  4. php artisan config:clear + restart workers
  5. Old key archived in secrets vault (for restoring old backups)

This is operationally heavy — most operators don't rotate. Set it up only if your compliance regime requires it.

What NOT to encrypt

You don't need to encrypt:

  • Public marketing pages
  • Static assets (CSS, JS, images)
  • Non-PII configuration (timezone settings, default templates)
  • Log files (rotate them quickly instead; over-encrypting logs makes debugging painful)
  • Plain-text email content WHILE IN TRANSIT through your sending pipeline (it's already encrypted on the wire via SMTP TLS; encrypting again wastes CPU)

Over-encryption is a real cost — slower performance, harder debugging, more failure modes. Encrypt what's actually risky.

Common mistakes

Mistake Why it bites
TLS 1.0/1.1 still enabled Deprecated; PCI-DSS non-compliant; some browsers will hard-fail
Self-signed cert in production Browsers show warnings; SES rejects connections; bad signal
InnoDB encryption enabled but keyring plugin not loaded MySQL won't start after ALTER TABLE ... ENCRYPTION='Y'
Backup passphrase stored in same backup directory "Encrypted backup" is encryption theater if the key is alongside it
APP_KEY in version control Anyone with git access can decrypt your sensitive fields
Restoring backup after APP_KEY rotation Old-key-encrypted fields can't be decrypted — you need to keep old keys
LUKS without a recovery plan Lose the passphrase = lose the data, period
Encrypting everything CPU overhead + debugging pain; encrypt only what's actually sensitive

FAQ

Is filesystem encryption enough? Do I still need MySQL encryption? Filesystem (LUKS) protects against physical theft of the disk. It does NOT protect against a logged-in attacker who can read DB files. MySQL InnoDB encryption adds a second layer.

TLS for DB connections from AcelleMail to MySQL? If MySQL is on the same host (loopback), no benefit. If MySQL is on a separate host (managed RDS, separate droplet), yes — set DB_SSL=true in .env + configure MySQL to require TLS.

End-to-end encryption for the emails themselves (S/MIME, PGP)? AcelleMail doesn't ship native support. Recipients would need to verify your signing key — operationally complex for marketing email. Only worth it for specific regulated transactional flows.

What about email content stored in the DB before send? AcelleMail stores campaign HTML in campaigns.html_content. With MySQL InnoDB encryption (Layer 2), this is encrypted at rest. Field-level encryption (Layer 4) would make the WYSIWYG editor + send pipeline significantly more complex; usually not worth it for marketing content.

Hardware Security Modules (HSM)? For regulated industries — AWS CloudHSM, Azure Key Vault HSM, etc. — store APP_KEY there instead of on the server filesystem. Reduces blast radius if the server is compromised. Niche; expensive.

Is bcrypt for passwords the same as encrypt()? No. bcrypt is a one-way HASH (can't be reversed). Used for storing user passwords (compare on login by hashing the attempt + comparing hashes). encrypt() is two-way (decrypt to recover the original). Use the right one for each use case.

What about subscriber unsubscribe tokens? These should be cryptographically signed (HMAC), not encrypted — they need to be readable by the unsubscribe URL endpoint but can't be forged. Laravel's signed URLs handle this natively.

Related articles

15 comments

6 comments

  1. anna.k.pm
    for hipaa — is acellemail considered a business associate? looking at whether we need a baa.
    1. admin
      Good question. The campaign:rerun audit writes to laravel.log only when the audit decides to force-resume — pure noop runs are silent. We'll add an info-level heartbeat in a future Acelle release to make it easier to monitor...
    2. admin (edited)
      Right — for RDS specifically, you can change wait_timeout via the parameter group without a reboot if it's set as 'dynamic'. Most defaults are.
  2. sofia.costa.pt
    Passed a SOC 2 audit last quarter using this as part of our documntation set. Auditors specifically noted the data-flow diagram was helpful.
    1. admin
      thanks for sharing. the pattern you describe is exactly the use case we built that feature for — glad it landed for you.
  3. i.rossi.mil
    the GDPR data-export article is what I sent to our DPO. Saved us meeting.
    1. admin
      Appreciate it. If anything in this needs updating, ping us — we revisit articles every few months. fwiw
  4. y.yamamoto
    Add audit logging for every admin action. We added a small middleware that logs to S3 — invaluable when answering compliance questions retroactively.
  5. emma.whitaker
    The GDPR data-export article is what I sent to our DPO. Saved us a meeting.
    1. admin (edited)
      thanks. pass it along if it helps your team.
  6. femi.adeyemi
    Passed a SOC 2 audit last quarter using this as part of our documentation set. Auditors specifically noted the data-flow diagram was helpful.
    1. admin
      useful context. the fact that it took 3 weeks end-to-end is realistic; we sometimes get pushed to say 1-week timelines and they're not honest.
    2. admin (edited)
      Thanks for the numbers. Worth pulling into a follow-up post on volume-tier sizing.
    3. admin (edited)
      Great real-world detail. Your point about stale running_pid > 30 min as an alert is something we should add to the diagnostic flow
    4. admin (edited)
      Confirming your experience matches what we see in support cases. Well cite the cause-#5 'wait it out' guidance more prominently in the next revision.

More in Security & Compliance