Skip to content

Web Server Configuration

Maho ships an Apache configuration in public/.htaccess. An Apache installation works after you point the document root at public/ and allow the file to run. For nginx, Caddy and FrankenPHP you must write the equivalent rules yourself. This page lists them.

public/.htaccess is the reference behaviour. The nginx and Caddy blocks on this page reproduce it. If the two ever disagree, the .htaccess file is correct.

The API needs these rules

The /api/* rules are part of the base configuration, not an extra. Without them the storefront still works and the API returns the storefront 404 page. Keep the rules even when every API protocol is off: a disabled protocol answers 404 at the entry point on its own.

What every web server must do

Every configuration must apply these rules:

  • Serve the public/ directory as the document root. Never expose the project root, because app/etc/local.xml holds the database password.
  • Send every request that does not match an existing file to public/index.php.
  • Route the /api/* paths as the API routing map below shows.
  • Execute PHP for the entry points only: index.php, rest.php and api.php.
  • Serve the files under /media, /skin and /js directly, and return 404 when the file is absent.
  • Deny access to hidden files. Allow two exceptions: /.well-known/, which is a registered path prefix and not a hidden file, and /.thumbs/, which holds the thumbnails of the admin media browser.
  • Deny access to the file types that disclose the installation: .txt, .json, .dist, .flag, .ip, .lock, .md, .neon, .sample, .sh, .yaml and .yml. Six names are exempt, and five of them are generated by PHP rather than stored on disk: /robots.txt, /llms.txt, /manifest.json, /api/docs.json, /.well-known/mcp.json and /.well-known/mcp/server-card.json.
  • Answer 405 to the TRACE and TRACK methods.
  • Send the X-Content-Type-Options: nosniff header.
  • Pass the Authorization header to PHP. Some FastCGI setups drop it, and the API needs it.

API routing map v26.7+

Maho has two API generations behind one /api/ prefix. Each path goes to a different entry point:

Path Entry point Handler
/api/rest/v2/* rest.php REST v2, Symfony API Platform
/api/graphql rest.php GraphQL, public
/api/admin/graphql rest.php GraphQL, admin
/api/mcp rest.php MCP, for AI agents
/api/docs rest.php OpenAPI documentation and Swagger UI
/api/rest api.php?type=rest Legacy REST, Magento 1
/api/soap index.php Mage_Api_SoapController
/api/v2_soap index.php Mage_Api_V2_SoapController
/api/xmlrpc index.php Mage_Api_XmlrpcController
/api/jsonrpc index.php Mage_Api_JsonrpcController
Any other /api/* rest.php REST v2, which answers 404

Two rules control the order:

  1. /api/rest/v2 must be tested before /api/rest. In the other order the legacy handler takes the v2 calls.
  2. The four SOAP and RPC paths must be excluded from the final catch-all. In the other order they reach rest.php instead of their own controllers.

Why rest.php, and not index.php?

rest.php boots the Symfony API Platform kernel directly. index.php boots the full Maho front-controller stack and then hands off to Symfony. The first path saves 50 to 100 ms per request. That matters for a client that makes 5 to 10 calls per user action, such as a POS terminal or a headless storefront. Both paths reach the same kernel. Maho is still initialised inside rest.php, so store context, models and configuration stay available.

There is no index.php fallback for the v2 paths. Without the rewrite rules, /api/* reaches the normal front controller, the legacy Mage_Api router claims the path, and the request fails.

Enable the protocols you route

Every protocol defaults to off in System → Configuration → Services → API → API Protocols. A disabled protocol answers 404 at the entry point, even with correct rewrite rules. The legacy SOAP and RPC protocols also need optional Laminas packages. See Legacy API Protocols.

CORS is not a web server setting

Do not add Access-Control-Allow-Origin headers to the web server, and do not answer the OPTIONS preflight there. Maho answers both inside the API kernel. Set the allowed origins in System → Configuration → Services → API → General Settings → CORS Allowed Origins.

A web server that adds the header sends it twice, and a browser rejects a response with two Access-Control-Allow-Origin headers. A web server that answers the preflight itself sends the wrong headers, because the kernel never sees the request. Maho also refuses a * wildcard on purpose, so a wildcard added in the web server defeats a deliberate control.

URLs that Maho normalizes itself v26.9+

Do not copy the URL clean-up rules of Magento 1 or OpenMage. Maho answers with a 301 redirect on its own for these cases:

Request Redirect target
/index.php /
/index.php/catalog/category/view/id/3 /catalog/category/view/id/3
/catalog//category /catalog/category
A URL with the wrong trailing-slash style The style that System > Configuration > Web > Url Options defines
A URL on a host that is not the base URL The base URL, when Auto-redirect to Base URL is on
An HTTP URL on a page that requires HTTPS The same URL over HTTPS

The redirect runs before the page renders, so it costs one request. It works the same on every web server. A store that is installed in a subdirectory keeps the subdirectory prefix.

Apache

Enable mod_rewrite, mod_headers and mod_expires. Then set the document root and allow the bundled .htaccess file to run:

<VirtualHost *:443>
    ServerName maho.example.com
    DocumentRoot /var/www/maho/public

    <Directory /var/www/maho/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Add nothing else. public/.htaccess already applies every rule on this page, the /api/* routing included. Do not copy the nginx or Caddy rules below into it.

If AllowOverride is None, Apache ignores public/.htaccess. The storefront then returns 404 on every page except the home page, and every API path fails.

nginx

nginx does not read .htaccess. Add a server block:

server {
    listen 443 ssl;
    server_name maho.example.com;

    root /var/www/maho/public;
    index index.php;

    client_max_body_size 64M;

    # Store view selection. Leave both empty for the default store view.
    set $mage_run_code "";
    set $mage_run_type "store";

    add_header X-Content-Type-Options "nosniff" always;

    # TRACE and TRACK allow cross-site tracing.
    if ($request_method ~ ^TRAC[EK]$) {
        return 405;
    }

    # nginx uses the first regular expression that matches, so the order of every
    # location block below is significant.

    # Hidden files. /.well-known/ is a registered path prefix, and /.thumbs/ holds
    # the thumbnails of the admin media browser.
    location ~ /\.(?!well-known/|thumbs/) {
        deny all;
    }

    # File types that disclose the installation. The exempt names below are
    # generated by PHP, so no file exists for them, but access control runs
    # before the rewrite and would still deny them.
    location ~* (?<!/robots)(?<!/llms)\.txt$ {
        deny all;
    }

    location ~* (?<!/manifest)(?<!/docs)(?<!/mcp)(?<!/server-card)\.json$ {
        deny all;
    }

    location ~* \.(dist|flag|ip|lock|md|neon|sample|sh|yaml|yml)$ {
        deny all;
    }

    # ---- API routing ----
    location ~ ^/api/rest/v2(/|$) {
        try_files $uri /rest.php$is_args$args;
    }

    location ~ ^/api/rest(/|$) {
        try_files $uri /api.php?type=rest&$args;
    }

    location ~ ^/api/(soap|v2_soap|xmlrpc|jsonrpc)(/|$) {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/api(/|$) {
        try_files $uri /rest.php$is_args$args;
    }
    # ---- End API routing ----

    # These three directories hold static files only. A missing file must give a
    # 404, and must not start a full Maho bootstrap.
    location ~ ^/(media|skin|js)/ {
        try_files $uri =404;
        expires 1y;
        access_log off;
    }

    # Only the entry points execute PHP.
    location ~ ^/(index|rest|api)\.php(/|$) {
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param SCRIPT_NAME $fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_param HTTPS $https if_not_empty;
        fastcgi_param MAGE_RUN_CODE $mage_run_code;
        fastcgi_param MAGE_RUN_TYPE $mage_run_type;
        # Keep this value equal to max_execution_time in php.ini.
        fastcgi_read_timeout 600;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    # Any other .php file is data, not code.
    location ~ \.php$ {
        return 404;
    }

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

The location ~ ^/(index|rest|api)\.php(/|$) block must come before the location ~ \.php$ block.

The four API blocks must keep the order above, for the reasons that the API routing map gives. The block for the SOAP and RPC paths replaces a negative condition: it matches those four paths first, so the catch-all below it never sees them.

If a site-wide auth_basic or IP allowlist protects the server, exempt the API. Add satisfy any; allow all; auth_basic off; to the four API blocks and to the PHP entry point block.

Optional: limit the request rate on the public mutation endpoints. Declare the zone in the http {} block, then add one more location before the four API blocks:

# In the http {} block:
#   limit_req_zone $binary_remote_addr zone=api_write:10m rate=10r/s;

location ~ ^/api/rest/v2/(newsletter|contact|auth/token|guest-carts) {
    limit_req zone=api_write burst=5 nodelay;
    try_files $uri /rest.php$is_args$args;
}

Caddy and FrankenPHP

Caddy sorts directives by type, not by the order you write them. The rewrite rules here depend on each other, so they must sit inside a route block. A route block runs its directives in source order. Without it, Caddy reorders the rewrites and the routing breaks in ways that are hard to see.

maho.example.com {
    root * /var/www/maho/public
    encode zstd br gzip

    header X-Content-Type-Options "nosniff"

    route {
        # TRACE and TRACK allow cross-site tracing.
        @trace method TRACE TRACK
        respond @trace 405

        # Hidden files. /.well-known/ is a registered path prefix, and /.thumbs/
        # holds the thumbnails of the admin media browser.
        @hidden {
            path_regexp hidden /\.
            not path /.well-known/*
            not path_regexp thumbs /\.thumbs/
        }
        respond @hidden 404

        # File types that disclose the installation. The exempt names are
        # generated by PHP, so no file exists for them, but this rule runs
        # before the rewrites and would still deny them. Go regular expressions
        # have no negative lookbehind, so the exceptions are `not path` lines.
        @private {
            path *.txt *.json *.dist *.flag *.ip *.lock *.md *.neon *.sample *.sh *.yaml *.yml
            not path /robots.txt /llms.txt /manifest.json /api/docs.json
            not path /.well-known/mcp.json /.well-known/mcp/server-card.json
        }
        respond @private 404

        # ---- API routing ----
        @api_v2 {
            path_regexp api_v2 ^/api/rest/v2(/|$)
            not file
        }
        rewrite @api_v2 /rest.php

        @api_legacy_rest {
            path_regexp api_legacy_rest ^/api/rest(/|$)
            not file
        }
        rewrite @api_legacy_rest /api.php?type=rest&{http.request.uri.query}

        @api_other {
            path_regexp api_other ^/api(/|$)
            not path_regexp api_legacy_rpc ^/api/(soap|v2_soap|xmlrpc|jsonrpc)(/|$)
            not file
        }
        rewrite @api_other /rest.php
        # ---- End API routing ----

        php_server
    }
}

Three details of this block are easy to get wrong:

  • The route block. See above. Caddy sorts directives by type when you omit it.
  • &{http.request.uri.query} on the legacy REST rewrite. A Caddy rewrite that writes its own query string replaces the original one. The placeholder puts the original back. Apache does the same thing with the [QSA] flag.
  • The two exceptions in @hidden. Caddy uses the Go regular expression engine, which has no negative lookahead. The matcher therefore needs one regular expression for the dot and two not conditions for the exceptions.

php_server already does the work of try_files and of the static file server, so no separate file_server directive is necessary. The not file condition on each API matcher stops a rewrite when a real file sits at that path.

The official Maho Docker images ship this site block, on the tags for Maho 26.7 and later. If you build your own image on the plain dunglas/frankenphp base, the default site block has none of these rules. See FrankenPHP.

Worker mode

FrankenPHP can keep PHP processes alive between requests, which removes the bootstrap cost:

{
    frankenphp {
        worker /var/www/maho/public/rest.php 4
    }
}

Mage::init() then runs once, and later requests reuse the bootstrap. Test the store before you use this in production, because a worker keeps state between requests.

A second store view on a second domain

Maho reads the store view from the MAGE_RUN_CODE and MAGE_RUN_TYPE environment variables, and never from the host name. To serve a second storefront on a second domain, set both variables in the virtual host of that domain.

Apache:

SetEnv MAGE_RUN_CODE french
SetEnv MAGE_RUN_TYPE store

nginx, in the server block of that domain:

set $mage_run_code french;
set $mage_run_type store;

Caddy, in the site block of that domain:

php_server {
    env MAGE_RUN_CODE french
    env MAGE_RUN_TYPE store
}

Use website as the value of MAGE_RUN_TYPE to select a website instead of a store view.

Test the configuration

Run these commands against a new installation. The storefront checks come first:

# The home page must answer 200.
curl -sI https://maho.example.com/ | head -1

# The bundled Apache file must answer 404.
curl -sI https://maho.example.com/.htaccess | head -1

# The document root must be public/, so the project files must stay out of reach.
curl -s https://maho.example.com/app/etc/local.xml | head -1

Read the body of the third command, not its status code. A correct server has no such file under public/, so it renders the storefront 404 page and answers 200. That 200 is not a fault. A first line that starts with <?xml is the fault: it means the document root is the project root and the database password is public. Do not write the check as curl .../../app/etc/local.xml, because curl removes the /../ before it sends the request.

On Maho 26.9 and later, /index.php must answer 301 and point at the root:

curl -sI https://maho.example.com/index.php | grep -iE '^(HTTP|location)'

Maho produces that redirect, not the web server, so a 200 means the request never reached PHP. Do not run this check on an earlier version. Maho serves /index.php with 200 before 26.9, and that is correct there.

Then check the /api/* paths. Each one proves a different rule:

curl -si https://maho.example.com/api/rest/v2/products | head -20
curl -si https://maho.example.com/api/rest/foo        | head -20
curl -si https://maho.example.com/api/mcp             | head -20
curl -si https://maho.example.com/api/soap/x          | head -20
Request Correct answer What it proves
/api/rest/v2/products 200 with application/ld+json, or 404 with {"error":"protocol_disabled"} when the protocol is off The v2 rule reaches rest.php
/api/rest/foo The same answer as a direct call to /api.php?type=rest The legacy REST rule reaches api.php
/api/mcp A JSON body: protocol_disabled, not_found when the symfony/mcp-bundle package is absent, or a real answer when MCP is on The catch-all reaches rest.php
/api/soap/x 301 to /api/soap/x/, then the Mage_Api SOAP controller answer The exclusion keeps SOAP on index.php

The Content-Type matters more than the status code. Every correct answer from rest.php is application/json or application/ld+json. A text/html body means the request reached the storefront instead.

The failure looks like success

A misrouted API path does not answer 404. On a store with no /api/* rules, Maho normalizes the URL, then renders the storefront 404 page with the status code 200:

GET /api/rest/v2/products
  correct rules:  404  application/json      {"error":"protocol_disabled", ...}
  no /api rules:  301  ->  /api/rest/v2/products/
                  200  text/html             <title>404 Not Found 1</title>

A client that only checks for a 2xx status therefore sees an HTML page where it expected JSON.

Compare the answers with an Apache installation of the same version if you are unsure. Apache runs the reference configuration, so the two must match.