Every new domain starts the same way. Copy a VirtualHost template, replace three strings, uncomment the SSL block after Certbot runs, verify the HTTP→HTTPS redirect is there. Repeat for aliases. The template is maybe 40 lines. After the tenth domain, you have ten variants, some with subtle differences: a missing AllowOverride All, a wrong log path, an alias that didn’t make it to the RewriteCond.

The other half of the problem is invisible. Apache’s default behavior when a VirtualHost doesn’t match is to fall through to the first .conf alphabetically. On a server with ten domains, an unrecognized request — a crawler, a scanner, a misconfigured DNS — silently gets served content from whichever site happens to sort first. No error page. No indication anything went wrong.

The fix for both problems is the same: stop treating VirtualHost configs as files you edit and start treating them as artifacts you generate.

The tool

@avelor/vhost is a Node.js CLI that takes a sites.yml and produces Apache configs.

sites:
  avelor.com:
    aliases: [www.avelor.com]
    mode: static
    root: /home/avelor/avelor

  api.avelor.es:
    mode: proxy
    port: 3000

sudo vhost apply generates the .conf files, runs a2ensite, runs apache2ctl configtest, and reloads Apache. Every generated file has # Generated by @avelor/vhost on the first line. That comment is load-bearing — vhost check reads it to distinguish generated configs from hand-written ones.

Gradual migration

The tool was designed around an existing server with hand-written configs. Requiring a full migration upfront is a non-starter: ten domains, some with complex rewrite rules, some with custom headers. One bad config and the whole server is down.

The managed marker solves this without ceremony. Every .conf that predates vhost apply shows up in vhost check as manual config — functional, not yet under source-of-truth control:

  ✓  avelor.com        managed   /home/avelor/avelor
  !  old-client.com    manual config   not in sites.yml
  !  staging.app.com   manual config   → run: vhost apply staging.app.com to migrate

Migration is one site at a time. Add the domain to sites.yml, run vhost apply <domain>, the generated config overwrites the manual one. When vhost check shows no manual config entries, the migration is complete.

Error pages via content negotiation

vhost apply installs built-in error pages for the full 4xx/5xx range. The design constraint: a server that hosts both web apps and APIs needs error responses in the right format depending on who’s asking.

The implementation uses Apache’s MultiViews content negotiation with static files. Each error code has three variants:

/var/www/vhost-errors/
  404.html   404.json   404.xml
  500.html   500.json   500.xml
  ...

The ErrorDocument directive points to the path without extension:

ErrorDocument 404 /vhost-errors/404

Apache picks .html, .json, or .xml based on the request’s Accept header. No PHP, no CGI, no runtime. The JSON response is {"code": 404, "message": "Page not found."}. The HTML page shows the status code large, a generic message, and Avelor · vhost fixed at the bottom.

Why open source

Avelor already publishes independent developer tools. The pattern is consistent: if the problem is universal and the solution has no coupling to private infrastructure, it goes public.

The Apache VirtualHost problem isn’t specific to Avelor. Any developer running multi-domain Apache on a VPS encounters it. The tool is a YAML-to-Apache-config transformer with a CLI and nothing else. Keeping it private would be the stranger choice.