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.phpmust 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).envfiles 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
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.
Multiple instances with different salts: If
instance_count > 1and 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.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 duringdocker 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
- Create database cluster in DO Console
- Create database:
wordpress_prod - Note connection details (host, port, user, password)
- 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
- Create new App in DO Console
- Connect to same repo,
stagingbranch - 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
- Create bucket in DO Console (e.g.,
yoursite-media) - Enable CDN (optional but recommended)
- Generate Spaces access key + secret
- 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:
- User uploads file
- Saved to local
/var/www/html/wp-content/uploads/(ephemeral) - Plugin uploads to Spaces
- 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.
Option 2: Apache Redirect (Recommended)
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: 1set in App Platform- SSL proxy headers configured
- Environment variables scoped to
RUN_TIMEwithtype: SECRETfor 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_HOSTincludes port:25060 - Verify database firewall rules in DO Console
- Ensure
MYSQL_CLIENT_FLAGS=SSLis set if cluster requires SSL
Media uploads work but disappear after deploy
- Set
remove-local-file: truein 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
Permalink 404s
- Ensure
a2enmod rewritein Dockerfile - Check
.htaccessrules (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)