> ## Content Index
> Fetch the complete content index at: https://blog-preview.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# WordPress Moved Into a Subdirectory: 500 Error, Wrong URLs and an Admin User From SQL
- URL: https://blog-preview.sudowl.com/wordpress-moved-to-subdirectory-500-error/
- Published: 2026-10-28T10:00:00.000Z
- Updated: 2026-11-05T18:02:37.000Z
- Description: Moved WordPress into /new/: fix RewriteBase, find a 500 with curl, set WP_HOME, rewrite links without breaking serialized data, add an admin via SQL.
- Author: Lukas
- Tags: Linux, Security

A client site got a new static landing page at the root. The old WordPress didn't get deleted, it got moved into a subfolder, `/new/`, so it stays reachable while the new site is built. Shared hosting, FTP and phpMyAdmin, no SSH, no WP-CLI.

Moving the files was the easy part. Getting `/new/` to actually load was not, because WordPress remembers where it lives in three different places, and every one of them was still wrong.

**TL;DR:** Fix `RewriteBase` and the last `RewriteRule` in `/new/.htaccess`. If you get a 500, curl a static file in the same folder: if that's 200, it's PHP, not rewrites. Set `WP_HOME` and `WP_SITEURL` in `wp-config.php` with your **real** domain, not the example one. Rewrite links in `wp_posts` with SQL, but never touch serialized data in `wp_options` or `wp_postmeta` with SQL `REPLACE`. Check `$table_prefix` before you write a single query, because the meta keys for user roles carry the prefix too.

## Step 1: the .htaccess still points at the root

This is the file that came along with the move:

```apache
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

```

`RewriteBase /` and `/index.php` send every pretty URL to the root, where there is no WordPress anymore. Two lines change:

```apache
RewriteBase /new/
RewriteRule . /new/index.php [L]

```

Everything else stays. Below the WordPress block there was a Wordfence section that denies access to `.user.ini`. That one can stay as it is.

Also check the `.htaccess` in the root. If the old WordPress block is still there, it rewrites requests for missing files to a `/index.php` that doesn't exist anymore. For a static root page, delete that block. If the new index is HTML, `DirectoryIndex index.html` makes that explicit.

## Step 2: the 500, and how to tell it's PHP

With the new `.htaccess` uploaded, `/new/` returned a 500\. Before guessing, I checked a few URLs from outside:

```bash
for u in https://example.com/ \
         https://example.com/new/ \
         https://example.com/new/readme.html \
         https://example.com/new/wp-login.php \
         https://example.com/new/wp-includes/css/dashicons.min.css; do
  echo "$u -> $(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$u")"
done

```

Result: root 200, `readme.html` 200, the CSS file 200\. `/new/` and `wp-login.php` 500.

That's the useful part. Static files from the same folder are served fine, so the rewrite rules aren't killing the request. Only PHP dies. That points to something that runs before or inside WordPress with a path that's now wrong.

## Gotcha: Wordfence and .user.ini

This site runs Wordfence. Its firewall puts a `.user.ini` into the WordPress folder with a line like this:

```ini
auto_prepend_file = '/path/to/webspace/wordfence-waf.php'

```

That's an absolute path. After the move, the real file sits under `/path/to/webspace/new/`, and `wordfence-waf.php` itself also contains absolute paths to `wp-content/wflogs/`. If PHP tries to prepend a file that isn't there, every PHP request fails before WordPress even starts.

The quick test is to switch it off:

1. In `/new/.user.ini` (and a `.user.ini` in the root, if there is one), comment the line out with `;`.
2. Rename `wp-content/plugins/wordfence` to `wordfence_off`.
3. Wait. Depending on the host, `.user.ini` is cached for a few minutes.

If that's not it, make the error visible in `wp-config.php` above `/* That's all, stop editing! */`:

```php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);

```

Then look at `wp-content/debug.log` or the error log in the hosting panel. Remove the debug lines afterwards.

I have to be honest here: about a minute later the 500 was gone, and I never got the log line that proves which change fixed it. The Wordfence path was the main suspect. If you hit this, look at the log instead of guessing like I had to.

## Step 3: the redirect to a domain that isn't yours

The next check showed this:

```text
/new/          -> 301 https://your-domain.example/new/
/new/wp-admin/ -> 302 https://your-domain.example/new/wp-login.php?...

```

PHP worked again, but every script tag, every link and the login now pointed to a placeholder domain. The snippet for `wp-config.php` I worked from had a placeholder in it, and it went into the file exactly like that. Easy mistake, and it looks like a brand-new problem when it happens.

The correct version, with the real domain and no trailing slash:

```php
define('WP_HOME',    'https://example.com/new');
define('WP_SITEURL', 'https://example.com/new');

```

These two constants win over `siteurl` and `home` in the database. That's handy for a quick fix, and confusing later, when you change the options in the database and nothing happens. Set both, or know which one is in charge.

## Step 4: find the table prefix first

The plan was one SQL script in phpMyAdmin: create an admin user, set `siteurl` and `home`, rewrite links in posts. It failed on the first statement, because I assumed `wp_`. The tables were called `wp_abc_users`, `wp_abc_options` and so on.

The prefix is in `wp-config.php` as `$table_prefix`, and the table list in phpMyAdmin shows it too. Look before writing anything. A failed first statement is the lucky case: nothing ran, nothing changed.

## Step 5: an admin user straight in the database

There was no working login on the moved site, so the user went in through SQL:

```sql
INSERT INTO wp_abc_users
  (user_login, user_pass, user_nicename, user_email, user_url,
   user_registered, user_activation_key, user_status, display_name)
VALUES
  ('alice', MD5('CHANGE-ME'), 'alice', 'alice@example.com', '',
   NOW(), '', 0, 'Alice');

SET @uid = LAST_INSERT_ID();

INSERT INTO wp_abc_usermeta (user_id, meta_key, meta_value) VALUES
  (@uid, 'wp_abc_capabilities', 'a:1:{s:13:"administrator";b:1;}'),
  (@uid, 'wp_abc_user_level',   '10');

```

Two things matter here:

- The meta keys carry the prefix: `wp_abc_capabilities`, not `wp_capabilities`. Get that wrong and the user exists, can maybe even log in, and has no rights at all.
- MD5 is only the way in. WordPress still accepts an old MD5 hash and replaces it with a proper hash on the first successful login. Don't leave it at that, log in once.

## Gotcha: "user created, but the password is wrong"

The user showed up in `wp_abc_users`, and the login still said wrong password. Two likely causes:

1. The placeholder was never replaced. Then the password really is `CHANGE-ME`, or whatever the placeholder said.
2. The script ran twice. In the standard WordPress schema the index on `user_login` isn't unique, as far as I know, so a second insert can go through and leave two users with the same name.

Check first:

```sql
SELECT ID, user_login, user_email, user_pass
FROM wp_abc_users WHERE user_login = 'alice';

```

One row: set the password again.

```sql
UPDATE wp_abc_users SET user_pass = MD5('your-real-password')
WHERE user_login = 'alice';

```

More than one row: delete the extra ones and their meta, keep the lowest ID, then run the `UPDATE`.

```sql
DELETE u, m FROM wp_abc_users u
LEFT JOIN wp_abc_usermeta m ON m.user_id = u.ID
WHERE u.user_login = 'alice'
  AND u.ID > (SELECT * FROM (SELECT MIN(ID) FROM wp_abc_users
              WHERE user_login = 'alice') x);

```

At the login form: use the username, not the email. Type the password by hand once, because a password manager with an old entry for that site will happily fill the wrong one. And Wordfence locks you out after a few failed tries. Its block tables have the prefix too (`wp_abc_wfblocks7`, `wp_abc_wflogins`). Emptying the blocks table lifts the lockout.

One more thing, since it happened: a password ended up in a screenshot in a chat. That password is burned. Pick a new one for the SQL and don't reuse it anywhere.

In my case the next message was already about links, so the login worked after this. I don't know which of the two causes it was.

## Step 6: rewriting the links without breaking anything

For post content, plain SQL is fine, because `post_content` isn't serialized. The order matters:

```sql
-- 1. unify all variants on one base
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'http://www.example.com',  'https://example.com');
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'https://www.example.com', 'https://example.com');
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'http://example.com',      'https://example.com');
-- 2. add the subfolder
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, 'https://example.com/', 'https://example.com/new/');
-- 3. fix links that were already right and are now doubled
UPDATE wp_abc_posts SET post_content = REPLACE(post_content, '/new/new/', '/new/');

```

The same three steps for `post_excerpt`. The `guid` column stays untouched. GUIDs are identifiers, not links, and feed readers use them.

What I did **not** do with SQL: `wp_options` (apart from `siteurl` and `home`) and `wp_postmeta`. Theme settings, widgets and the cookie banner plugin store serialized PHP there. A serialized string stores its own length, like `s:22:"https://example.com/x"`. A plain `REPLACE` changes the text but not the number, PHP can't unserialize it anymore, and the setting is silently gone. For those tables use a tool that understands serialization, like the Better Search Replace plugin after login, or `wp search-replace` if you have WP-CLI. Dry run first, GUIDs off.

## Gotcha: the menu wasn't in the posts

After all that, I fetched the page and counted link targets. 51 URLs in the HTML pointed to `https://example.com/new`. Five didn't: the header menu, still pointing to `https://www.example.com/#section`. Those were custom menu links. They live in `wp_abc_postmeta` under `_menu_item_url`, as plain text, not serialized, so SQL is safe here:

```sql
UPDATE wp_abc_postmeta
SET meta_value = REPLACE(REPLACE(REPLACE(meta_value,
  'https://www.example.com/', 'https://example.com/'),
  'http://www.example.com/',  'https://example.com/'),
  'https://example.com/',     'https://example.com/new/')
WHERE meta_key = '_menu_item_url'
  AND meta_value LIKE '%example.com/%'
  AND meta_value NOT LIKE '%/new/%';

```

Yoast keeps its own copy of every permalink in `wp_abc_yoast_indexable`. Same idea, `permalink` column, with the `NOT LIKE '%/new/%'` guard. Then in the admin, Settings, Permalinks, click Save once.

I can't tell you from my notes that the menu fix was actually run. The page check before it is the last thing I saw.

## Checklist

1. `/new/.htaccess`: `RewriteBase /new/` and `RewriteRule . /new/index.php [L]`. Root `.htaccess` without the old block.
2. 500? Curl a static file in the same folder. 200 means PHP. Check `.user.ini` and the security plugin's absolute paths, then the error log.
3. `WP_HOME` and `WP_SITEURL` with the real domain.
4. `$table_prefix` before any SQL. Prefixed meta keys for the user role.
5. Database backup before the first `UPDATE`.
6. Posts with SQL. Options and postmeta only with a serialization-aware tool. Menu links and SEO plugin tables extra.
7. Fetch the page and count the link targets that are still wrong.

If the move is part of a bigger server change, build a test copy first, like in [build a full test copy before you touch DNS](/server-migration-test-copy-before-dns/). If files and database come from different places, [restoring a site from different backups](/restore-site-files-and-database-from-different-backups/) has the `siteurl` checks. And for rewrite rules on a non-WordPress app in a folder, see [static SPA on Apache with .htaccess fallback](/static-spa-apache-htaccess-fallback/).

---

### Related posts

- [Website Cert Fine, Mail Cert Expired: Plesk's Stale Postfix SNI Map](https://blog.sudowl.com/plesk-mail-certificate-expired-postfix-sni-map/?ref=blog-preview.sudowl.com)
- [New Mail Domain From Zero: SPF, DKIM and DMARC Passing on Day One](https://blog.sudowl.com/new-mail-domain-spf-dkim-dmarc-from-zero/?ref=blog-preview.sudowl.com)
- [DMARC Reports Explained: What the rua XML Actually Tells You](https://blog.sudowl.com/dmarc-reports-explained-rua-xml/?ref=blog-preview.sudowl.com)