One of the services on this server is a REST API for a field data collection project. It receives session data from a desktop/web application, stores it in SQLite, manages user accounts, and serves PDF reports. Before it landed here, it lived on shared hosting.

The migration was supposed to be straightforward: copy the files, update the config, point DNS. Instead, it became a security audit.

What we found

Hardcoded credentials in source code. A PHP file contained plaintext username/password pairs for a remote API connection. Not in a config file, not in environment variables — inline in the source code. The file was deleted, its functionality replaced with a local alternative.

SQL injection. Multiple endpoints constructed SQL queries by concatenating user input directly into query strings. The standard pattern:

// Before (vulnerable)
$sql = "SELECT * FROM sessions WHERE UserID = '" . $_POST['user'] . "'";

// After (parameterized)
$sql = "SELECT * FROM sessions WHERE UserID = ?";
$stmt->execute([$_POST['user']]);

Every query in the codebase was rewritten to use parameterized statements.

Remote CMS dependency. User authentication worked by making REST API calls to a Drupal instance on another server. The Drupal site was the source of truth for user accounts, passwords, and roles. This meant the data collection API couldn’t function if the Drupal server was down.

We replaced this with local authentication against a SQLite database, using the same password hashing algorithm (Drupal 7’s SHA-512 scheme). Users were migrated once, and the remote dependency was eliminated.

The Apache-to-nginx translation

The original API used Apache’s .htaccess for URL rewriting — routing all requests through a front controller (index.php). The nginx equivalent is a single try_files directive:

location / {
    try_files $uri /index.php$is_args$args;
}

One line replaces an entire .htaccess file. But you have to remember that .htaccess files don’t work in nginx at all — they’re silently ignored. If the original developer was relying on .htaccess for access control or rewrites, those rules evaporate when you switch web servers.

The mail function swap

The old code used PHP’s mail() function for sending notifications. On shared hosting, mail() works because the hosting provider configures a local MTA. In a Docker container, there’s no local MTA.

Every mail() call was replaced with a shared SMTP helper that authenticates against the local mail server. Same functionality, but with proper authentication, DKIM signatures, and reliable delivery.

What I took away

Migrating off shared hosting isn’t just a infrastructure change — it’s an opportunity to fix the security shortcuts that shared hosting encourages. When deployment is “upload via FTP,” security practices tend to match. When deployment involves Docker containers, config files, and explicit network policies, the code gets held to a higher standard.

Every migration is a code review in disguise.