I needed to serve a separate frontend app, a Vite build, at /editor on the same domain as a Drupal site. No subdomain, no port, just domain.com/editor pointing cleanly to the app's index.html while everything else continued hitting Drupal normally.
Sounded simple at first. I took a couple of wrong turns to get it right. Here's the working setup.
The Goal
domain.com/ → Drupal (existing)
domain.com/editor → custom-app/dist/index.html (separate Vite app)
The underlying path I wanted to hide: /var/www/custom-app/dist/index.html. The URL I wanted to expose: /editor.
Wrong Turn 1: The Second Server Block
My first attempt was adding a second server {} block in NGINX for the editor, containing just the location /editor block. That immediately broke the Drupal site with a 404.
The problem: a bare location block without any listen or server_name directives isn't valid on its own. NGINX either ignored the block or used it as a default, routing requests somewhere unexpected. The server blocks were stepping on each other.
The fix: Don't create a second server block. Add the location block inside the existing server block for the domain.
Wrong Turn 2: root vs. alias
When you have a location block that maps to a path that doesn't match the URL structure, you need alias, not root.
root appends the request URI to the path:
# Requesting /editor → looks for /var/www/custom-app/dist/editor
location /editor {
root /var/www/custom-app/dist;
}
alias replaces the matched part of the URI with the specified path:
# Requesting /editor → looks for /var/www/custom-app/dist/index.html
location /editor {
alias /var/www/custom-app/dist;
try_files $uri $uri/ /editor/index.html;
}
The alias directive is what you want here.
The Working Configuration
Here's what I added inside the existing domain.com server block (in my case, managed through Laravel Forge at /etc/nginx/sites-available/domain.com):
# Add this inside your existing server {} block
location /editor {
alias /var/www/custom-app/dist;
index index.html;
try_files $uri $uri/ /editor/index.html;
}
The try_files line ensures that any path under /editor/* falls back to index.html — essential for single-page apps that use client-side routing. Without it, refreshing a deep route like /editor/canvas/123 returns a 404.
After adding this, test your config before reloading:
sudo nginx -t
sudo systemctl reload nginx
Coexisting with Drupal
The key thing that makes this work alongside Drupal is placement. The Drupal configuration likely has a catch-all location / block that passes requests to PHP-FPM:
location / {
try_files $uri /index.php?$query_string;
}
The /editor location block needs to appear before this catch-all, or be more specific, so NGINX matches it first. NGINX uses the most specific matching location block, so a literal /editor prefix match will take precedence over the / catch-all regardless of order — but being explicit about placement avoids surprises.
End State
GET / → Drupal via PHP-FPM
GET /editor → custom-app/dist/index.html
GET /editor/ → custom-app/dist/index.html
GET /editor/foo → custom-app/dist/index.html (SPA fallback)
GET /editor/app.js → custom-app/dist/app.js (static asset)
Clean URLs, one domain, two completely separate apps. The alias + try_files combo is the right tool for this — once you know to reach for alias instead of root, it's a pretty painless setup.