Moving a WordPress Site onto My Own Debian Server (Without Breaking Everything Else on It)

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:

  1. The website, a WordPress install, served over HTTPS, with plain HTTP redirecting to HTTPS.
  2. SSL for the new domain without touching any of the other sites’ certificates or configs.
  3. 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:

PlaceholderWhat it is
example.comThe server’s primary domain. Its mail host is mail.example.com
example.netAnother domain that was already hosted on the server
example.orgThe domain I was moving
dev@example.orgThe one and only mailbox the new domain should have
exampleorgThe Linux user that owns that mailbox
203.0.113.10The server’s public IP
/var/www/example.orgWhere 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:

  1. 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.
  2. Switch DNS.
  3. Get the certificate and turn on HTTPS.
  4. 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:

  1. Install the stack, if the server doesn’t already have it. The command is below the list.
  2. Create a database and a database user just for this site, using sudo mysql.
  3. Download WordPress from wordpress.org and unpack it into the site’s folder, here /var/www/example.org. Then give Apache ownership with sudo chown -R www-data:www-data /var/www/example.org.
  4. Create a plain HTTP (port 80) vhost for the domain, then enable it with sudo a2ensite.
  5. Run the web installer, which creates wp-config.php and 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 at user@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.net through its wildcard. A user called dev would create dev@example.com and dev@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 localhost and got Connection refused. On my server localhost resolves to the IPv6 address ::1 first, Postfix only listens on IPv4, and swaks gives up after the first address fails. Using 127.0.0.1 fixed 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.

TypeNameValue
A@203.0.113.10
Awww203.0.113.10
MX (priority 10)@mail.example.com
TXT@v=spf1 ip4:203.0.113.10 ~all
TXTdefault._domainkeyv=DKIM1; h=sha256; k=rsa; p=MIIB...
TXT_dmarcv=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=none in 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

SettingValue
From Emaildev@example.org
Force From Email✅ ticked, so other plugins such as contact forms can’t override it
From Namethe site name
MailerOther 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.

SettingValue
SMTP Host127.0.0.1
EncryptionNone
SMTP Port25
Auto TLSOff, because the mail certificate isn’t issued for 127.0.0.1, so TLS verification would fail
AuthenticationOff

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:

  1. From an outside account such as Gmail, send to dev@example.org and check it arrives.
  2. 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
Usernameexampleorg (the Linux user, not the email address)
Passwordthe one set with adduser

What I learned

  • Find out how your mail server works before changing anything. Five minutes with postconf -n and doveconf -n saved 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 configtest before every reload. A broken config affects every site on the server, not just the new one.
  • certbot certonly --webroot keeps 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 localhost gives “connection refused”, try 127.0.0.1. IPv6 strikes again.