The main web application on this server has both a PHP backend and a Node.js backend. This isn’t the result of a migration gone sideways or a team disagreement about technology. It’s deliberate.

What each does

PHP is the primary API layer. It handles user authentication, data synchronization, license management, and all the CRUD operations. Each endpoint is a separate file — login.php, sync.php, checkin.php. This file-per-endpoint pattern makes it trivial to find, read, and modify any API endpoint. No routing framework, no middleware chain, no dependency injection. Just a PHP file that receives a POST, does some work, and returns JSON.

Node.js handles two things that PHP can’t do well:

  1. Puppeteer — headless Chrome for server-side HTML-to-PDF conversion. PHP has PDF libraries (TCPDF, Dompdf), but none of them render complex HTML/CSS the way a real browser does. Puppeteer runs an actual Chromium instance and produces pixel-perfect PDFs.

  2. PayPal SDK — PayPal’s server-side integration is a Node.js SDK. Their PHP SDK exists but is less maintained and less documented. The Node implementation is first-class.

How they coexist

Four Docker containers, two networks:

  • nginx — public-facing, routes requests based on URL path
  • PHP-FPM — handles /api/*.php requests
  • Node.js (Express) — handles /api/js/* requests
  • An internal Docker network connects nginx to both backends

The nginx config is the glue:

location ~ \.php$ {
    fastcgi_pass php-fpm:9000;
}

location /api/js/ {
    proxy_pass http://node:8888/api/js/;
}

PHP can also proxy to Node when needed. The HTML-to-PDF endpoint is a PHP file that receives the request, does authentication, then forwards the HTML payload to the Node.js Puppeteer service. The caller doesn’t know (or care) which backend processed their request.

The shared database

Both backends read and write to the same SQLite database. WAL (Write-Ahead Logging) mode enables concurrent access — PHP and Node.js can read simultaneously, and writes are serialized without blocking reads.

The database file is mounted as a directory volume (not a file volume) because SQLite’s WAL mode creates companion files (-wal and -shm) alongside the main database. If you mount just the file, Docker can’t see the companion files and WAL breaks silently. This is one of those lessons you learn exactly once.

Why not just Node.js for everything?

Because PHP is better at being a simple API server. A PHP file is a self-contained unit — it starts, processes one request, and exits. No event loop, no connection pooling, no memory leaks from long-running processes. For request-response APIs backed by SQLite, PHP’s simplicity is a feature.

Node.js excels at the things that need a persistent runtime: maintaining a Chromium process pool, handling WebSocket connections, managing stateful SDK clients. Using it for simple CRUD would add complexity without benefit.

The right tool for each job. Sometimes that means two tools.