For years one of my sites lived with a shared hosting provider. Meanwhile I already had my own Debian server running a handful of other websites on Apache, with Let’s Encrypt certificates and a Postfix + Dovecot mail setup that I’d put together long enough ago that I no longer remembered how it worked.
Moving the site over sounded simple, and mostly it was. But there were three things I really wanted to get right:
- The website, a WordPress install, served over HTTPS, with plain HTTP redirecting to HTTPS.
- SSL for the new domain without touching any of the other sites’ certificates or configs.
- Exactly one email address for the new domain, able to both send and receive, without accidentally creating mailboxes for every other user on the server.
This post is everything I did, in the order I’d do it again, including the bits that tripped me up.
The names in this post
I’ve anonymised everything. Wherever you see these, substitute your own:
| Placeholder | What it is |
|---|---|
example.com | The server’s primary domain. Its mail host is mail.example.com |
example.net | Another domain that was already hosted on the server |
example.org | The domain I was moving |
dev@example.org | The one and only mailbox the new domain should have |
exampleorg | The Linux user that owns that mailbox |
203.0.113.10 | The server’s public IP |
/var/www/example.org | Where WordPress is installed |
Order matters
The one thing that shapes the whole process: Let’s Encrypt can only issue a certificate once the domain’s DNS points at your server. It checks that you own the domain by fetching a file over plain HTTP. So the order is:
- Set up the website, the mailbox and DKIM on the new server while DNS still points at the old host. Nothing breaks, and you can test most of it privately.
- Switch DNS.
- Get the certificate and turn on HTTPS.
- Test everything.
A day before switching, lower the TTL on your existing DNS records to something like 300 seconds. That way the switch takes effect in minutes rather than hours. And if your DNS is hosted by the old provider, move it to your registrar (or wherever you’ll keep it) before cancelling that account, or your domain will stop resolving entirely.
Step 0: Install WordPress the normal way
This post starts from a working WordPress install on the new server. Getting there is the standard WordPress-on-LAMP installation (Linux, Apache, MySQL/MariaDB, PHP), and I didn’t do anything unusual, so I won’t repeat it here. The official guide covers it well: How to install WordPress.
In short, on Debian:
- Install the stack, if the server doesn’t already have it. The command is below the list.
- Create a database and a database user just for this site, using
sudo mysql. - Download WordPress from wordpress.org and unpack it into the site’s folder, here
/var/www/example.org. Then give Apache ownership withsudo chown -R www-data:www-data /var/www/example.org. - Create a plain HTTP (port 80) vhost for the domain, then enable it with
sudo a2ensite. - Run the web installer, which creates
wp-config.phpand the admin account.
The packages for step 1:
sudo apt install apache2 mariadb-server php libapache2-mod-php php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl
One tip for step 5 when DNS still points at the old host: use the hosts-file trick from Step 1 and run the installer at http://example.org, not the server’s IP address. WordPress saves whatever address you install it from as its site address. Installing through the domain means Step 8 is a simple switch from http:// to https:// rather than a clean-up of IP addresses.
Step 1: Check the WordPress vhost
I’d already installed WordPress on the server with a plain port 80 virtual host. First I checked that Apache had picked it up correctly and nothing else was claiming the same name:
sudo apache2ctl -S | grep -i example.org
WordPress needs mod_rewrite for its pretty permalinks:
sudo a2enmod rewrite
Testing before DNS changes: add a line to the hosts file on your own computer so that the domain resolves to the new server for you alone:
203.0.113.10 example.org www.example.org
On Linux/macOS that’s /etc/hosts. On Windows it’s C:\Windows\System32\drivers\etc\hosts. Browse to the site, check it works, then remove the line so you don’t confuse yourself later.
Step 2: Work out how the mail server is actually set up
Mail setups come in several flavours: Linux system users, “virtual” mailboxes stored in files, or mailboxes in a database managed by something like PostfixAdmin. The steps for adding a domain are completely different for each, so first you have to find out which one you have.
These three commands answered it for me:
sudo postconf -n | grep -E 'mydestination|virtual_|smtpd_sender|milter|mydomain|myhostname'
sudo doveconf -n | grep -A4 -E 'passdb|userdb'
systemctl list-units --type=service | grep -Ei 'opendkim|rspamd|spamassassin|postfixadmin'
What they told me (trimmed):
mydestination = $myhostname, example.com, localhost.example.com, localhost, mail.example.com
virtual_alias_domains = example.net
virtual_alias_maps = hash:/etc/postfix/virtual
smtpd_milters = local:opendkim/opendkim.sock
passdb { driver = pam }
userdb { driver = passwd }
opendkim.service loaded active running OpenDKIM Milter
Decoded, that means:
- Mailboxes are ordinary Linux users. Dovecot logs you in with your Linux username and password (PAM).
- The primary domain is in
mydestination, so every Linux user automatically receives mail atuser@example.com. - Other domains are “alias domains”: Postfix accepts mail for them, then rewrites the address according to
/etc/postfix/virtual. - OpenDKIM signs outgoing mail.
The existing /etc/postfix/virtual had exactly one line:
@example.net @example.com
That’s a whole-domain rewrite. It turns anything@example.net into anything@example.com, which then lands in the Linux user called anything. That’s why every account on the server “worked” for the other domain.
Step 3: One mailbox, one address
I did not want to copy that wildcard for the new domain, for two reasons.
I only wanted dev@example.org to exist. With the wildcard, every Linux user on the server would get an @example.org address.
Wildcards cause backscatter. With @domain in the map, Postfix accepts mail for any name at that domain, including names that don’t exist, and only bounces it afterwards. Spammers fake the sender address, so those bounces land on innocent people, and your server’s reputation suffers. With an explicit address instead, mail to a name that doesn’t exist is refused while the sending server is still connected, and no bounce is ever sent.
The mailbox user
The email address and the Linux username don’t have to match. The virtual map connects them. I named the Linux user exampleorg rather than dev for two reasons:
- It’s obvious what the account is for.
- Every Linux user automatically gets an address on the primary domain, and here also on
example.netthrough its wildcard. A user calleddevwould createdev@example.comanddev@example.net, and generic addresses like that are exactly what spammers guess.
sudo adduser --shell /usr/sbin/nologin exampleorg
The password you set here is the mail password. Because the account has no login shell, nobody can use it to SSH in.
Add the domain to Postfix
This appends the new domain to the existing list rather than replacing it:
sudo postconf -e "virtual_alias_domains = $(sudo postconf -h virtual_alias_domains) example.org"
sudo postconf virtual_alias_domains
Map exactly one address
Add one line to /etc/postfix/virtual:
dev@example.org exampleorg
Then rebuild the lookup table and reload Postfix:
sudo postmap /etc/postfix/virtual
sudo systemctl reload postfix
Check the mapping. This should print exampleorg:
sudo postmap -q dev@example.org hash:/etc/postfix/virtual
Test it locally
swaks is the Swiss Army knife for SMTP testing (sudo apt install swaks). You can test delivery on the server itself before DNS has moved:
swaks --to dev@example.org --server 127.0.0.1
And an address that shouldn’t exist, which should be refused with 550 5.1.1 ... User unknown in virtual alias table:
swaks --to info@example.org --server 127.0.0.1
Gotcha: my first attempt used
--server localhostand gotConnection refused. On my serverlocalhostresolves to the IPv6 address::1first, Postfix only listens on IPv4, and swaks gives up after the first address fails. Using127.0.0.1fixed it.
Check the mail log to confirm delivery. On newer Debian releases without rsyslog, use journalctl -u 'postfix*' instead:
sudo grep example.org /var/log/mail.log | tail -20
Can the mailbox send?
My main.cf had no sender restrictions. But the submission port (587) can have its own settings in master.cf, so it’s worth a look:
sudo grep -A15 -E '^(submission|smtps)' /etc/postfix/master.cf
No reject_sender_login_mismatch there, so the logged-in exampleorg user can send as dev@example.org straight away.
Step 4: DKIM with OpenDKIM
Without DKIM, Gmail and Outlook will happily send your mail to junk. First I checked how OpenDKIM was configured:
sudo grep -Ev '^\s*(#|$)' /etc/opendkim.conf
The important lines were:
KeyTable refile:/etc/opendkim/key.table
SigningTable refile:/etc/opendkim/signing.table
That’s the multi-domain layout. (If you only see a single Domain / KeyFile / Selector instead, OpenDKIM only signs one domain, and you’ll need to switch it to the table layout first.) The existing tables used the selector default and kept keys under /etc/opendkim/keys/<domain>/, so I followed the same pattern.
Generate a key:
sudo mkdir -p /etc/opendkim/keys/example.org
sudo opendkim-genkey -b 2048 -d example.org -D /etc/opendkim/keys/example.org -s default -v
sudo chown -R opendkim:opendkim /etc/opendkim/keys/example.org
sudo chmod 600 /etc/opendkim/keys/example.org/default.private
Add to /etc/opendkim/signing.table:
*@example.org default._domainkey.example.org
*@*.example.org default._domainkey.example.org
Add to /etc/opendkim/key.table:
default._domainkey.example.org example.org:default:/etc/opendkim/keys/example.org/default.private
Leave trusted.hosts alone. It lists the machines allowed to send mail through OpenDKIM for signing, not domains. Then restart OpenDKIM:
sudo systemctl restart opendkim
My Postfix has milter_default_action = accept, so mail keeps flowing during the restart. Anything sent in that moment just goes out unsigned.
Finally, show the public key to put in DNS:
sudo cat /etc/opendkim/keys/example.org/default.txt
Step 5: DNS
Here’s the full set of records for the new domain. I copied the MX and SPF style from the domains already on the server.
| Type | Name | Value |
|---|---|---|
| A | @ | 203.0.113.10 |
| A | www | 203.0.113.10 |
| MX (priority 10) | @ | mail.example.com |
| TXT | @ | v=spf1 ip4:203.0.113.10 ~all |
| TXT | default._domainkey | v=DKIM1; h=sha256; k=rsa; p=MIIB... |
| TXT | _dmarc | v=DMARC1; p=none; rua=mailto:dev@example.org |
A few notes:
- MX points at the server’s existing mail hostname. The mail server’s TLS certificate already covers that name, so I didn’t have to touch Dovecot or Postfix’s SSL config at all.
- The DKIM file splits the key into several quoted chunks. Most DNS panels want it as one continuous string with the quote marks removed.
- Remove any MX, SPF or DKIM records left over from the old host.
p=nonein DMARC is deliberately gentle. It only asks receiving servers to send reports. Tighten it once everything passes.
Then wait until the domain resolves to the new server:
dig +short example.org
Step 6: A Let’s Encrypt certificate that doesn’t touch the other sites
The usual certbot --apache works, but it edits Apache configs for you. On a server with several other sites, I wanted certbot to do nothing except fetch a certificate. certonly --webroot does exactly that:
sudo certbot certonly --webroot -w /var/www/example.org -d example.org -d www.example.org
This creates a separate certificate under /etc/letsencrypt/live/example.org/. If certbot ever offers to expand an existing certificate belonging to another site, say no.
WordPress’s .htaccess rewrite rules leave real files alone, so certbot’s check file under .well-known/acme-challenge/ is served normally.
The trap to avoid: don’t write the port 443 vhost before the certificate exists. Apache refuses a config that points at certificate files that aren’t there, and if you reload with a broken config, every site on the server is affected, not just the new one. Certificate first, then vhost.
Also check the SSL module is on, and that certbot’s shared settings file exists. It will if other sites were previously set up with certbot’s Apache plugin:
sudo a2enmod ssl
ls /etc/letsencrypt/options-ssl-apache.conf
Step 7: The final vhost, with HTTP redirecting to HTTPS
Back up the current vhost first:
sudo cp /etc/apache2/sites-available/example.org.conf /root/example.org.conf.bak
Then replace its contents with this:
# Port 80: redirect everything to HTTPS, except Let's Encrypt renewal challenges
<VirtualHost *:80>
ServerName example.org
ServerAlias www.example.org
DocumentRoot /var/www/example.org
RedirectMatch 301 ^/(?!\.well-known/acme-challenge/)(.*)$ https://example.org/$1
ErrorLog ${APACHE_LOG_DIR}/example.org_error.log
CustomLog ${APACHE_LOG_DIR}/example.org_access.log combined
</VirtualHost>
# Port 443: WordPress
<VirtualHost *:443>
ServerName example.org
ServerAlias www.example.org
DocumentRoot /var/www/example.org
<Directory /var/www/example.org>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.org/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ErrorLog ${APACHE_LOG_DIR}/example.org_error.log
CustomLog ${APACHE_LOG_DIR}/example.org_access.log combined
</VirtualHost>
The redirect skips /.well-known/acme-challenge/, so automatic renewals keep working over plain HTTP without depending on the HTTPS side. If options-ssl-apache.conf didn’t exist, delete the Include line.
Always run configtest before reloading:
sudo apache2ctl configtest
sudo systemctl reload apache2
A reload is graceful. The other sites keep serving throughout.
Step 8: Tell WordPress it lives on HTTPS now
WordPress stores its own address in the database, and until you change it to https:// you’ll get redirect loops or broken logins. First I checked whether wp-config.php set the address:
sudo grep -E "WP_HOME|WP_SITEURL" /var/www/example.org/wp-config.php
It didn’t, so the address was in the database. To look at it you need the database name:
sudo grep DB_NAME /var/www/example.org/wp-config.php
(Yes, I first ran the next command with a literal DBNAME placeholder in it, and MySQL correctly told me that table doesn’t exist.)
sudo mysql -e "SELECT option_name, option_value FROM wordpress_exampleorg.wp_options WHERE option_name IN ('siteurl','home');"
Both came back as http://example.org, which is normal for a fresh install. There are two ways to switch it. Do this only after HTTPS works, otherwise you’ll send yourself to an address that doesn’t work yet.
Option A: set it in wp-config.php. Add these just above /* That's all, stop editing! */:
define('WP_HOME', 'https://example.org');
define('WP_SITEURL', 'https://example.org');
This is the most reliable way, because it doesn’t depend on being able to log in first. The downside is that the address fields in Settings → General become greyed out.
Option B: update the database directly. This keeps the fields editable:
sudo mysql -e "UPDATE wordpress_exampleorg.wp_options SET option_value='https://example.org' WHERE option_name IN ('siteurl','home');"
Pick one. If you’re migrating existing content rather than starting fresh, posts and images still contain the old http:// links. With WP-CLI you can fix them all at once:
sudo -u www-data wp search-replace 'http://example.org' 'https://example.org' --skip-columns=guid --path=/var/www/example.org
(The “Better Search Replace” plugin does the same from the dashboard.)
Step 9: WordPress’s own email
WordPress sends password resets and notifications from wordpress@example.org by default. Those messages go out through Postfix and are DKIM-signed, since the signing table covers *@example.org. But wordpress@ doesn’t exist as a mailbox, so any reply or bounce is refused.
First, set Settings → General → Administration Email Address to dev@example.org.
Then, to change the From address, I used WP Mail SMTP by WPForms. There are several plugins with similar names; this is the one whose author is listed as WPForms.
Skip the setup wizard
After activation it launches a wizard that insists you pick an “SMTP provider”: Gmail, SendGrid, “Other SMTP” and so on. You don’t need any of them. WordPress runs on the same server as Postfix, so there’s nothing external to connect to. Exit the wizard with the “Go back to the Dashboard” link at the bottom and go to WP Mail SMTP → Settings instead.
The settings
| Setting | Value |
|---|---|
| From Email | dev@example.org |
| Force From Email | ✅ ticked, so other plugins such as contact forms can’t override it |
| From Name | the site name |
| Mailer | Other SMTP |
| Return Path | ✅ ticked (“Set the return-path to match the From Email”) |
Default (none) hands mail straight to the local Postfix, and OpenDKIM signs it for example.org automatically.
Return Path matters too. Without it, bounces go to the web server’s system account (something like www-data@example.com), where nobody will ever see them. With it ticked, they go to dev@example.org.
Other SMTP Setup
During the initial setup wizard (or from settings later), choose Other SMTP and point it at the local Postfix. That doesn’t need a login, because Postfix trusts connections from the server itself.
| Setting | Value |
|---|---|
| SMTP Host | 127.0.0.1 |
| Encryption | None |
| SMTP Port | 25 |
| Auto TLS | Off, because the mail certificate isn’t issued for 127.0.0.1, so TLS verification would fail |
| Authentication | Off |
Don’t be tempted to use mail.example.com:587 with the mailbox username and password instead. It works, but it stores the mailbox password in the WordPress database for no real benefit.
Test it
Go to WP Mail SMTP → Tools → Email Test and send a test to an outside address. In Gmail, “Show original” should show dkim=pass with d=example.org.
The plugin will keep nudging you towards its Pro version and a paid mailer service. You don’t need either.
Step 10: Test everything
Website. The first should return a 301 to https://example.org/, the second a 200:
curl -I http://example.org
curl -I https://example.org
Certificate renewal:
sudo certbot renew --dry-run
DKIM. You want key OK. key not secure next to it is normal; it just means your DNS doesn’t use DNSSEC:
sudo opendkim-testkey -d example.org -s default -vvv
Mail, for real:
- From an outside account such as Gmail, send to
dev@example.organd check it arrives. - From
dev@example.org, send to the address shown on mail-tester.com. It reports SPF, DKIM and DMARC separately and tells you exactly what’s wrong if one fails. Gmail’s “Show original” gives a quick pass/fail too.
Mail client settings
| Value | |
|---|---|
| Incoming (IMAP) | mail.example.com, port 993, SSL/TLS |
| Outgoing (SMTP) | mail.example.com, port 587, STARTTLS, authentication on |
| Username | exampleorg (the Linux user, not the email address) |
| Password | the one set with adduser |
What I learned
- Find out how your mail server works before changing anything. Five minutes with
postconf -nanddoveconf -nsaved me from following a guide written for a completely different setup. - The email address and the mailbox don’t have to share a name. Postfix’s virtual map connects them, so you can give the Linux account a name that says what it’s for.
- Avoid wildcard alias domains unless you really want every user on every domain. An explicit address gets unknown recipients refused immediately, and you avoid backscatter.
- Get the certificate before writing the HTTPS vhost, and run
apache2ctl configtestbefore every reload. A broken config affects every site on the server, not just the new one. certbot certonly --webrootkeeps certbot’s hands off your configs, and leaving/.well-known/acme-challenge/out of the redirect keeps renewals working.- WordPress has to be told about HTTPS. Apache serving it over HTTPS isn’t enough.
- If
localhostgives “connection refused”, try127.0.0.1. IPv6 strikes again.