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 bình luận

6 bình luận

  1. y.yamamoto
    Add audit logging for every admin action. We added a small middleware that logs to S3 — invaluable when answering compliance questions retroactively.
  2. 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 (đã chỉnh sửa)
      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.
  3. 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.
  4. 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
  5. emma.whitaker
    The GDPR data-export article is what I sent to our DPO. Saved us a meeting.
    1. admin (đã chỉnh sửa)
      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 (đã chỉnh sửa)
      Thanks for the numbers. Worth pulling into a follow-up post on volume-tier sizing.
    3. admin (đã chỉnh sửa)
      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 (đã chỉnh sửa)
      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