IIS
Exponential runs on Windows with IIS, PHP for Windows (the non-thread-safe build) over FastCGI and the IIS
URL Rewrite module. web.config in the installation root carries everything: the PHP handler, the
rewrite rules of .htaccess_root, request filtering and the security headers.
Warning
.htaccess slows down every request. When .htaccess files are enabled (AllowOverride anything but None), let alone
used, Apache looks for them and reads and evaluates them on every request, in every directory of the path, and
page loading times go up drastically (measured). The only real fix is to switch the feature off for
the virtual host: AllowOverride None in the HTTPS virtual host, and the rules in the virtual host's own
configuration as mod_rewrite rules. That is far faster in production speed tests and well worth the time it takes
to optimize a production configuration. Switch over right away: copy the rules of the
example HTTPS virtual host into your virtual host (with the static cache, also
staticcache_offload.conf), set AllowOverride None, run apachectl configtest, reload Apache and
test the site. The same rules for other servers: nginx, LiteSpeed,
Caddy, IIS, Velocity (built in); where virtual host rules go in a control
panel: Plesk, cPanel, DirectAdmin, ISPConfig.
On LiteSpeed or OpenLiteSpeed, the server's own page cache can also answer anonymous visitors and crawlers without PHP: Page caching with LiteSpeed and OpenLiteSpeed.
Note
This example is derived, not tested on IIS: it was checked for well-formed XML only (xmllint). Its
rewrite rules are .htaccess_root's, whose behavior was tested in Apache and nginx. Test it on your server with the
requests below before you rely on it, and compare it with .htaccess_root after every upgrade. See
Operating system support for what is known about Windows.
IIS never reads .htaccess: exp:setup:htaccess does not protect an IIS site, web.config does. config.php is
put into place and edited the same way on every server, with exp:setup:config and exp:config:set; see
Setting up .htaccess and config.php from the console.
Install it
- Install IIS with the CGI feature (it brings FastCGI), the
URL Rewrite 2.1 module, and PHP for Windows (NTS, x64).
Register
php-cgi.exeunder FastCGI Settings and give itmemory_limit,upload_max_filesizeandpost_max_sizein itsphp.ini. - Point the site's physical path at the installation root (where
index.phpis). - Copy the file there as
web.config; adjustscriptProcessorto yourphp-cgi.exe. - Give the application pool's identity write access to
var/(and read access to the rest). - Bind HTTPS with your certificate in the site's bindings, and add an HTTP-to-HTTPS redirect (URL Rewrite or the HTTP Redirect feature) on the port 80 binding.
The file
<!--
Exponential 6: IIS (Internet Information Services) with PHP over FastCGI
Free documentation: GFDL 1.3 or later, see doc/eng-us/README-LICENSE.md; rules derived from .htaccess_root (GPL v2 or later).
Derived, not shipped by the kernel, and not tested on IIS here: a
translation of .htaccess_root (this release) into IIS URL Rewrite rules,
checked for well-formed XML only. Test it on your server before you rely
on it, and compare it with .htaccess_root after every upgrade.
Explained in iis.md next to this file.
Needs: the IIS URL Rewrite module 2.x, the CGI feature (FastCGI) and PHP
for Windows (NTS build) registered in FastCGI Settings. Put this file as
web.config in the installation root (the site's physical path, where
index.php is). Adjust scriptProcessor to your php-cgi.exe.
-->
<configuration>
<system.webServer>
<defaultDocument enabled="true">
<files>
<clear />
<add value="index.php" />
</files>
</defaultDocument>
<!-- PHP over FastCGI. Only the front controllers are ever reached:
the rewrite rules below send nothing else to a .php file. -->
<handlers>
<remove name="PHP_via_FastCGI" />
<add name="PHP_via_FastCGI" path="*.php" verb="GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"
modules="FastCgiModule" scriptProcessor="C:\PHP\php-cgi.exe"
resourceType="File" requireAccess="Script" />
</handlers>
<!-- Defense in depth. Request filtering runs before URL Rewrite and answers
404 for these names and suffixes whatever the rules below say. The
rules alone already keep settings/, var/, vendor/, kernel/ and the
rest from being sent (every path not on the static list is rewritten
to index.php). hiddenSegments matches a segment ANYWHERE in the path,
so it lists only names no URL alias of a site can have: adding
"settings", "update" or "doc" would also hide pages such as
/about/settings. Uploads: maxAllowedContentLength in bytes (64 MB). -->
<security>
<requestFiltering allowDoubleEscaping="false">
<requestLimits maxAllowedContentLength="67108864" />
<hiddenSegments applyToWebDAV="true">
<add segment=".git" />
<add segment=".env" />
<add segment=".htaccess" />
<add segment="composer.json" />
<add segment="composer.lock" />
</hiddenSegments>
<fileExtensions allowUnlisted="true">
<add fileExtension=".ini" allowed="false" />
<add fileExtension=".db" allowed="false" />
<add fileExtension=".sqlite" allowed="false" />
<add fileExtension=".log" allowed="false" />
<add fileExtension=".phtml" allowed="false" />
<add fileExtension=".phar" allowed="false" />
</fileExtensions>
</requestFiltering>
</security>
<rewrite>
<rules>
<!-- 1. No dot file or dot directory, anywhere; .well-known stays reachable -->
<rule name="exp-dot-paths" stopProcessing="true">
<match url="(^|/)\.(?!well-known(/|$))" />
<action type="CustomResponse" statusCode="404" statusReason="Not Found" statusDescription="Not Found" />
</rule>
<!-- 2. No script below the root runs because its path was asked for -->
<rule name="exp-scripts-below-root" stopProcessing="true">
<match url="^.+/[^/]+\.(php[0-9]?|phtml|phar)$" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" />
</conditions>
<action type="CustomResponse" statusCode="404" statusReason="Not Found" statusDescription="Not Found" />
</rule>
<!-- 3. Front controllers -->
<rule name="exp-rest-api" stopProcessing="true">
<match url="^api/" />
<action type="Rewrite" url="index_rest.php" />
</rule>
<rule name="exp-rest-direct" stopProcessing="true">
<match url="^index_rest\.php" />
<action type="None" />
</rule>
<rule name="exp-treemenu" stopProcessing="true">
<match url="^([^/]+/)?content/treemenu.*" />
<action type="Rewrite" url="index_treemenu.php" />
</rule>
<!-- 4. Files served as they are: the list of .htaccess_root -->
<rule name="exp-static" stopProcessing="true">
<match url="^(var/([^/]+/)?storage/images(-versioned)?/.*|var/([^/]+/)?storage/pdf/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$|var/([^/]+/)?storage/original/image/.+\.(png|jpe?g|gif|webp|svg)$|var/([^/]+/)?cache/(texttoimage|public)/.*|design/[^/]+/(stylesheets|images|javascript|fonts)/.*|share/icons/.*|extension/[^/]+/icons/[A-Za-z0-9_/-]+\.(svg|png|gif)$|extension/[^/]+/design/[^/]+/(stylesheets|flash|images|lib|javascripts?|fonts|vendor|media)/.*|packages/styles/.+/(stylesheets|images|javascript)/[^/]+/.*|packages/styles/.+/thumbnail/.*|var/storage/packages/.+\.(png|jpe?g|gif|webp)$|favicon\.ico|design/standard/images/favicon\.ico|robots\.txt|index\.js$|sw\.js$|w3c/p3p\.xml)" />
<action type="None" />
</rule>
<!-- With site.ini [FileSettings] CacheVarDir=var_cache (multi-site hosting),
add var_cache/([^/]+/)?cache/(texttoimage|public)/.* to the list above. -->
<!-- 5. Everything else goes to index.php -->
<rule name="exp-front-controller" stopProcessing="true">
<match url=".*" />
<action type="Rewrite" url="index.php" />
</rule>
</rules>
<outboundRules>
<!-- Uploaded SVG images run nothing and load nothing when opened directly -->
<rule name="exp-svg-sandbox" preCondition="exp-is-svg">
<match serverVariable="RESPONSE_Content_Security_Policy" pattern=".*" />
<action type="Rewrite" value="sandbox; default-src 'none'; style-src 'unsafe-inline'" />
</rule>
<preConditions>
<preCondition name="exp-is-svg">
<add input="{RESPONSE_CONTENT_TYPE}" pattern="^image/svg\+xml" />
</preCondition>
</preConditions>
</outboundRules>
</rewrite>
<!-- The values of the kernel's site.ini [HTTPHeaderSettings] SecurityHeaders[]
for the files IIS serves itself. IIS adds them to PHP's answers too:
remove the same header from SecurityHeaders[] in site.ini, or keep both
equal, so a page does not carry two different values. -->
<httpProtocol>
<customHeaders>
<remove name="X-Powered-By" />
<add name="X-Content-Type-Options" value="nosniff" />
<add name="X-Frame-Options" value="SAMEORIGIN" />
<add name="Referrer-Policy" value="strict-origin-when-cross-origin" />
<add name="Permissions-Policy" value="camera=(), microphone=(), payment=(), usb=()" />
</customHeaders>
</httpProtocol>
<staticContent>
<remove fileExtension=".webp" />
<mimeMap fileExtension=".webp" mimeType="image/webp" />
<remove fileExtension=".woff2" />
<mimeMap fileExtension=".woff2" mimeType="font/woff2" />
</staticContent>
</system.webServer>
</configuration>How it maps
.htaccess_root | web.config |
|---|---|
dot rule, [R=404,L] | rule exp-dot-paths, CustomResponse 404 (IIS URL Rewrite has look-ahead, the pattern is the same) |
script rule with %{REQUEST_FILENAME} -f | rule exp-scripts-below-root with {REQUEST_FILENAME} IsFile |
api/, index_rest.php, tree menu | rules exp-rest-api, exp-rest-direct, exp-treemenu |
the - [L] asset rules | one rule exp-static with action type="None": the file is served as it is |
RewriteRule .* index.php | rule exp-front-controller |
the SVG FilesMatch block | outbound rule exp-svg-sandbox on image/svg+xml responses |
IIS URL Rewrite matches the path without its leading slash, as Apache does in .htaccess, so the patterns are
.htaccess_root's unchanged.
Request filtering answers 404 before any rewrite rule runs. It is defense in depth: the rewrite rules alone
already send settings/, var/, vendor/, kernel/ and composer.json to index.php. hiddenSegments matches a
segment anywhere in a path, so it lists only names no page of a site can have (.git, .env, .htaccess,
composer.json, composer.lock): listing settings or doc would also hide a page such as /about/settings. The
fileExtensions list refuses .ini, .db, .sqlite, .log, .phtml and .phar everywhere.
Headers. customHeaders adds the kernel's default security headers to every response, PHP's included. Keep the
values equal to site.ini [HTTPHeaderSettings] SecurityHeaders[], or remove the header from one of the two, so a page
never carries two different values.
WebDAV
This web.config does not route WebDAV (off by default). To serve it, add a rule for ^index_webdav\.php with
<action type="None" /> before the catch-all, or a site of its own that rewrites everything to index_webdav.php,
and keep IIS's own WebDAV module off for it. Since 6.0.15 the script is index_webdav.php; a rule written for
webdav.php keeps working through a forwarding shim until a later major version. Before/after and the test:
The WebDAV entry script is now index_webdav.php.
Check it
foreach ($p in '/.git/config','/.env','/kernel/classes/ezini.php','/extension/ezjscore/ezinfo.php') {
try { (Invoke-WebRequest "https://example.com$p" -UseBasicParsing).StatusCode } catch { $_.Exception.Response.StatusCode.value__ }
} # 404 each
(Invoke-WebRequest https://example.com/settings/site.ini -UseBasicParsing).Content -match '\[DatabaseSettings\]' # FalseRelated pages
- Apache .htaccess: the rules explained one by one.
- Chapter 13, section 13.2: What the web server must never hand out.
- Operating system support, Web server examples and all examples.
- Setting up .htaccess and config.php from the console: which servers read
.htaccess, andconfig.php. - Free documentation: this page is free to copy, translate and improve.