Post

wordpress digitalocean migration guide

WordPress Migration to DigitalOcean App Platform with Docker

Overview

This guide walks through migrating a production WordPress site to DigitalOcean’s App Platform using Docker containers. App Platform uses ephemeral filesystems, so all persistent data (database, media) must be external.

Key architectural constraints:

  • App Platform filesystem is ephemeral (wiped on each deploy)
  • SSL is terminated by DO’s proxy (WordPress sees HTTP internally)
  • Database must be DO Managed MySQL
  • Media must be in DO Spaces
  • wp-config.php must not be committed (use env vars)

1. Repository Structure

What to commit:

1
2
3
4
5
6
7
8
9
10
11
.
├── Dockerfile
├── .dockerignore
├── wp-content/
│   ├── themes/
│   │   └── your-custom-theme/
│   └── plugins/
│       ├── your-plugin/
│       └── premium-plugin/  # Premium plugins committed here
├── uploads.ini              # PHP upload limits
└── README.md

What NOT to commit:

  • wp-config.php (generated at runtime via env vars)
  • wp-content/uploads/ (stored in Spaces)
  • .env files with secrets
  • Database dumps with credentials

.dockerignore

1
2
3
4
5
6
7
.git
.github
.env
*.sql
*.sql.gz
wp-content/uploads
node_modules

Why: Reduces image size and prevents secrets from being baked into Docker layers.


2. Dockerfile Setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM wordpress:php8.3-apache

# Install additional PHP extensions if needed
RUN docker-php-ext-install mysqli pdo pdo_mysql

# Custom PHP configuration for uploads
COPY uploads.ini /usr/local/etc/php/conf.d/uploads.ini

# Copy custom theme and plugins
COPY wp-content/themes /var/www/html/wp-content/themes
COPY wp-content/plugins /var/www/html/wp-content/plugins

# Set correct ownership
RUN chown -R www-data:www-data /var/www/html/wp-content

# Enable Apache mod_rewrite for permalinks
RUN a2enmod rewrite

# Enable Apache headers module for redirects
RUN a2enmod headers

# Apache configuration for proxy headers
RUN echo "SetEnvIf X-Forwarded-Proto https HTTPS=on" > /etc/apache2/conf-available/proxy-headers.conf \
    && a2enconf proxy-headers

uploads.ini

1
2
3
4
5
6
file_uploads = On
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
max_input_time = 300

Why: The official WordPress image has conservative upload limits. App Platform needs these customizations to handle media uploads during migration and admin tasks.


3. WordPress Configuration via Environment Variables

Do not create wp-config.php in your repo. The official WordPress image generates it from env vars.

Required Environment Variables

Set these in App Platform (App Settings → Environment Variables):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# Database
WORDPRESS_DB_HOST=your-db-cluster.db.ondigitalocean.com:25060
WORDPRESS_DB_USER=doadmin
WORDPRESS_DB_PASSWORD=your_secure_password
WORDPRESS_DB_NAME=wordpress_prod
MYSQL_CLIENT_FLAGS=SSL

# WordPress URLs
WORDPRESS_CONFIG_EXTRA=<<EOF
define('WP_HOME', 'https://yoursite.com');
define('WP_SITEURL', 'https://yoursite.com');

/* SSL behind proxy - CRITICAL for App Platform */
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

/* Authentication salts - MUST BE PINNED */
define('AUTH_KEY',         'your-generated-salt-here');
define('SECURE_AUTH_KEY',  'your-generated-salt-here');
define('LOGGED_IN_KEY',    'your-generated-salt-here');
define('NONCE_KEY',        'your-generated-salt-here');
define('AUTH_SALT',        'your-generated-salt-here');
define('SECURE_AUTH_SALT', 'your-generated-salt-here');
define('LOGGED_IN_SALT',   'your-generated-salt-here');
define('NONCE_SALT',       'your-generated-salt-here');

/* DigitalOcean Spaces for media */
define('AS3CF_SETTINGS', serialize([
    'provider' => 'do',
    'access-key-id' => getenv('SPACES_ACCESS_KEY'),
    'secret-access-key' => getenv('SPACES_SECRET_KEY'),
]));
define('WPOS3_SETTINGS', serialize([
    'bucket' => getenv('SPACES_BUCKET'),
    'region' => getenv('SPACES_REGION'),
    'domain' => 'cdn',
    'cloudfront' => getenv('SPACES_CDN_URL'),
]));
EOF

# Spaces credentials (for WP Offload Media)
SPACES_ACCESS_KEY=your_spaces_key
SPACES_SECRET_KEY=your_spaces_secret
SPACES_BUCKET=your-bucket-name
SPACES_REGION=nyc3
SPACES_CDN_URL=https://your-bucket-name.nyc3.cdn.digitaloceanspaces.com

Generate Salts

1
curl https://api.wordpress.org/secret-key/1.1/salt/

CRITICAL: Pin these salts. Do not use dynamic salt generation. If salts change between deploys, all users are logged out.


4. Why WordPress Login Loops Happen on App Platform

Problem

You can log in, but immediately get redirected back to /wp-login.php in an infinite loop.

Root Causes

  1. SSL proxy mismatch: App Platform terminates SSL. WordPress sees HTTP internally but thinks it’s HTTPS externally. Without proxy headers, WordPress generates HTTP URLs and the browser redirects to HTTPS, breaking cookies.

  2. Multiple instances with different salts: If instance_count > 1 and salts are dynamic, each container generates different salts. User logs in on instance A, next request hits instance B with different salts = invalid auth cookie.

  3. Changing salts on redeploy: If salts are not pinned, every deploy generates new salts = all users logged out.

Solution

Set instance_count: 1 in App Platform config:

1
2
3
workers:
  - name: wordpress
    instance_count: 1  # REQUIRED for App Platform

Why: App Platform doesn’t support sticky sessions. Multiple instances + session-based auth = broken authentication. WordPress stores sessions in the database, but auth cookies are validated against salts that must be identical across all instances.

Add proxy header handling in WORDPRESS_CONFIG_EXTRA:

1
2
3
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

Why: This ensures WordPress knows the frontend is HTTPS even though Apache sees HTTP.

Pin your salts (see section 3 above).


5. App Platform Configuration

App Spec (.do/app.yaml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
name: wordpress-prod

services:
  - name: wordpress
    dockerfile_path: Dockerfile
    github:
      repo: your-username/your-repo
      branch: main
      deploy_on_push: true

    instance_count: 1
    instance_size_slug: professional-xs

    http_port: 80

    routes:
      - path: /

    envs:
      # Scoped to runtime only (not available during build)
      - key: WORDPRESS_DB_HOST
        value: "your-db.db.ondigitalocean.com:25060"
        scope: RUN_TIME

      - key: WORDPRESS_DB_USER
        value: "doadmin"
        scope: RUN_TIME

      - key: WORDPRESS_DB_PASSWORD
        value: "your_password"
        scope: RUN_TIME
        type: SECRET

      - key: WORDPRESS_DB_NAME
        value: "wordpress_prod"
        scope: RUN_TIME

      - key: WORDPRESS_CONFIG_EXTRA
        value: "... (see section 3)"
        scope: RUN_TIME

      - key: SPACES_ACCESS_KEY
        value: "your_key"
        scope: RUN_TIME
        type: SECRET

      - key: SPACES_SECRET_KEY
        value: "your_secret"
        scope: RUN_TIME
        type: SECRET

databases:
  - name: wordpress-db
    engine: MYSQL
    production: true
    version: "8"

Environment Variable Scoping

  • RUN_TIME: Available to the running container (database, WordPress config)
  • BUILD_TIME: Available during docker build (rarely needed for WordPress)
  • BUILD_AND_RUN_TIME: Available in both phases

Use RUN_TIME for all WordPress variables. Database credentials should never be baked into Docker images.


6. Database Migration

Step 1: Dump Production Database

From your current hosting:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Export database
mysqldump -h current-host -u user -p database_name \
  --single-transaction \
  --quick \
  --lock-tables=false \
  > wordpress_prod.sql

# Or with compression
mysqldump -h current-host -u user -p database_name \
  --single-transaction \
  --quick \
  --lock-tables=false \
  | gzip > wordpress_prod.sql.gz

Step 2: Create DigitalOcean Managed MySQL

  1. Create database cluster in DO Console
  2. Create database: wordpress_prod
  3. Note connection details (host, port, user, password)
  4. Download CA certificate if using SSL

Step 3: Handle sql_require_primary_key

DO Managed MySQL has sql_require_primary_key=ON by default. Old WordPress dumps may have tables without primary keys.

Check your dump:

1
grep -i "CREATE TABLE" wordpress_prod.sql

If tables lack primary keys, disable the check temporarily:

1
2
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p \
  -e "SET GLOBAL sql_require_primary_key=0;"

Step 4: Import Database

1
2
3
4
5
# Uncompressed
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p wordpress_prod < wordpress_prod.sql

# Compressed
gunzip < wordpress_prod.sql.gz | mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p wordpress_prod

Re-enable primary key requirement after import:

1
2
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p \
  -e "SET GLOBAL sql_require_primary_key=1;"

Step 5: Update WordPress URLs

1
2
3
4
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p wordpress_prod

UPDATE wp_options SET option_value = 'https://yoursite.com' WHERE option_name = 'siteurl';
UPDATE wp_options SET option_value = 'https://yoursite.com' WHERE option_name = 'home';

Common MySQL Errors

Error 1045: Access denied

  • Check username/password
  • Verify IP is whitelisted in DO database firewall
  • Ensure using correct port (25060 for DO Managed MySQL)

Error 1046: No database selected

1
mysql -h host -P 25060 -u user -p database_name  # Note: database name at end

Error 1227: Access denied for DEFINER Old dumps may reference users that don’t exist. Remove DEFINER clauses:

1
sed 's/DEFINER=[^ ]*//g' wordpress_prod.sql > wordpress_prod_cleaned.sql

Error 3750: sql_require_primary_key See Step 3 above.


7. Creating a Staging Environment

Step 1: Create Staging Branch

1
2
git checkout -b staging
git push origin staging

Step 2: Create Staging Database

1
2
3
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p

CREATE DATABASE wordpress_staging;

Import production data:

1
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p wordpress_staging < wordpress_prod.sql

Update URLs:

1
2
3
4
mysql -h your-db.db.ondigitalocean.com -P 25060 -u doadmin -p wordpress_staging

UPDATE wp_options SET option_value = 'https://staging-yoursite.ondigitalocean.app' WHERE option_name = 'siteurl';
UPDATE wp_options SET option_value = 'https://staging-yoursite.ondigitalocean.app' WHERE option_name = 'home';

Step 3: Create Staging App in App Platform

  1. Create new App in DO Console
  2. Connect to same repo, staging branch
  3. Use separate environment variables:
1
2
3
4
5
6
7
8
9
envs:
  - key: WORDPRESS_DB_NAME
    value: "wordpress_staging"

  - key: WORDPRESS_CONFIG_EXTRA
    value: |
      define('WP_HOME', 'https://staging-yoursite.ondigitalocean.app');
      define('WP_SITEURL', 'https://staging-yoursite.ondigitalocean.app');
      /* ... same proxy and salt config ... */

Step 4: Separate Spaces Bucket (Optional)

Create your-bucket-staging to isolate staging media from production.


8. DigitalOcean Spaces + WP Offload Media

Why Media Must Be in Spaces

App Platform has ephemeral storage. Uploads saved locally disappear on redeploy.

Install WP Offload Media Lite

1
2
3
4
5
6
cd wp-content/plugins
wget https://downloads.wordpress.org/plugin/amazon-s3-and-cloudfront.latest-stable.zip
unzip amazon-s3-and-cloudfront.latest-stable.zip
rm amazon-s3-and-cloudfront.latest-stable.zip
git add amazon-s3-and-cloudfront
git commit -m "Add WP Offload Media Lite"

Create Spaces Bucket

  1. Create bucket in DO Console (e.g., yoursite-media)
  2. Enable CDN (optional but recommended)
  3. Generate Spaces access key + secret
  4. Set CORS policy if uploading directly from frontend

Configure via Environment Variables

Add to WORDPRESS_CONFIG_EXTRA:

1
2
3
4
5
6
7
8
9
10
11
12
define('AS3CF_SETTINGS', serialize([
    'provider' => 'do',
    'access-key-id' => getenv('SPACES_ACCESS_KEY'),
    'secret-access-key' => getenv('SPACES_SECRET_KEY'),
]));

define('WPOS3_SETTINGS', serialize([
    'bucket' => getenv('SPACES_BUCKET'),
    'region' => getenv('SPACES_REGION'),
    'domain' => 'cdn',
    'cloudfront' => getenv('SPACES_CDN_URL'),
]));

Set env vars:

1
2
3
4
5
SPACES_ACCESS_KEY=DO00ABC123...
SPACES_SECRET_KEY=secret...
SPACES_BUCKET=yoursite-media
SPACES_REGION=nyc3
SPACES_CDN_URL=https://yoursite-media.nyc3.cdn.digitaloceanspaces.com

Why no GUI config: Keeps secrets out of the database and repo. Environment variables are the only secure method for App Platform.

Pre-Sync Existing Uploads to Spaces

Before deploying to App Platform, sync existing media:

1
2
3
4
5
6
# Install s3cmd or aws cli configured for DO Spaces
s3cmd sync /path/to/wp-content/uploads/ s3://yoursite-media/wp-content/uploads/

# Or with aws cli
aws s3 sync /path/to/wp-content/uploads/ s3://yoursite-media/wp-content/uploads/ \
  --endpoint-url=https://nyc3.digitaloceanspaces.com

Why Offload Media Errors Occur

WP Offload Media assumes files exist locally before uploading to Spaces. On App Platform:

  1. User uploads file
  2. Saved to local /var/www/html/wp-content/uploads/ (ephemeral)
  3. Plugin uploads to Spaces
  4. On next deploy, local file is gone

If the plugin tries to regenerate thumbnails or access the local file, it fails.

Solution: Ensure WP Offload Media is set to “Remove Files From Server” after upload. Check plugin settings or add to config:

1
2
3
4
5
6
define('AS3CF_SETTINGS', serialize([
    'provider' => 'do',
    'access-key-id' => getenv('SPACES_ACCESS_KEY'),
    'secret-access-key' => getenv('SPACES_SECRET_KEY'),
    'remove-local-file' => true,  // Critical for ephemeral filesystem
]));

9. Handling Legacy Media URLs

Problem

Old posts and pages still reference:

1
https://yoursite.com/wp-content/uploads/2023/05/image.jpg

But files are now at:

1
https://yoursite-media.nyc3.cdn.digitaloceanspaces.com/wp-content/uploads/2023/05/image.jpg

Option 1: Database Search-Replace

1
2
3
4
wp search-replace \
  'https://yoursite.com/wp-content/uploads' \
  'https://yoursite-media.nyc3.cdn.digitaloceanspaces.com/wp-content/uploads' \
  --all-tables

Risk: Can break serialized data. Use WP-CLI’s built-in serialized data handling.

Add to Dockerfile:

1
2
3
4
RUN echo '<Directory /var/www/html/wp-content/uploads>\n\
    RedirectMatch 301 ^/wp-content/uploads/(.*)$ https://yoursite-media.nyc3.cdn.digitaloceanspaces.com/wp-content/uploads/$1\n\
</Directory>' > /etc/apache2/conf-available/uploads-redirect.conf \
    && a2enconf uploads-redirect

Why this works:

  • Requests to /wp-content/uploads/... hit Apache
  • Apache 301 redirects to Spaces CDN URL
  • No database changes needed
  • Works for hardcoded URLs in old content

Test Redirects

1
2
3
4
5
curl -I https://yoursite.com/wp-content/uploads/2023/05/test.jpg

# Should return:
HTTP/2 301
Location: https://yoursite-media.nyc3.cdn.digitaloceanspaces.com/wp-content/uploads/2023/05/test.jpg

10. Final Production Hardening Checklist

Pre-Launch

  • Database backed up and imported successfully
  • All media files synced to Spaces
  • WP Offload Media configured with remove-local-file: true
  • Apache redirects tested for legacy media URLs
  • Salts pinned in WORDPRESS_CONFIG_EXTRA
  • instance_count: 1 set in App Platform
  • SSL proxy headers configured
  • Environment variables scoped to RUN_TIME with type: SECRET for passwords
  • Database firewall allows App Platform outbound IPs
  • DNS updated to point to App Platform URL

Post-Launch

  • Test login (no loops)
  • Test admin dashboard loads correctly
  • Test media upload → check Spaces bucket
  • Test permalink structure works (/blog/post-name/)
  • Verify HTTPS everywhere (no mixed content warnings)
  • Check PHP error logs in App Platform console
  • Monitor database connections (watch for connection limit errors)
  • Test staging environment deploys independently

Ongoing Maintenance

  • Set up automated database backups (DO Managed DB includes daily backups)
  • Monitor Spaces storage usage and costs
  • Review App Platform logs for PHP warnings/errors
  • Document any custom plugins that need updating
  • Plan for scaling: if traffic grows, investigate caching layers (Redis, Varnish) before adding instances

Common Pitfalls

“Error establishing a database connection”

  • Check WORDPRESS_DB_HOST includes port :25060
  • Verify database firewall rules in DO Console
  • Ensure MYSQL_CLIENT_FLAGS=SSL is set if cluster requires SSL

Media uploads work but disappear after deploy

  • Set remove-local-file: true in WP Offload Media config
  • Verify Spaces credentials are correct
  • Check plugin logs in WordPress admin

Login works locally but not on App Platform

  • Pin your salts (see section 3)
  • Set instance_count: 1
  • Add SSL proxy header handling
  • Ensure a2enmod rewrite in Dockerfile
  • Check .htaccess rules (usually auto-generated by WordPress)

Slow admin dashboard

  • App Platform cold starts can take 10-20 seconds
  • Consider upgrading instance size
  • Add Redis object cache (requires persistent Redis instance, not ephemeral)

Resources

This post is licensed under CC BY 4.0 by the author.