-
Notifications
You must be signed in to change notification settings - Fork 15
Running on nginx
FreeITSM runs on nginx, but nginx needs a configuration file that Apache does not, and that file is not optional. Without it your ticket attachments are downloadable by anyone who knows the URL.
The config ships in the repository at deploy/nginx/freeitsm.conf.
Related: Root folder tidy Β· issue #68
Apache reads .htaccess files scattered through the application and applies whatever it finds. nginx has no equivalent and never reads them. Neither does Apache itself when a site is configured with AllowOverride None.
FreeITSM ships twelve .htaccess files. Three are conveniences. Nine are security controls.
That distinction is the whole of this page. If the pretty URLs were all that was at stake, running without them would just be untidy. What is actually at stake is this, measured on a real nginx serving a real FreeITSM:
| Request | Plain nginx | With deploy/nginx/freeitsm.conf
|
|---|---|---|
/tickets/attachments/0/514/<file>.pdf |
200 β 404,048 bytes served | 404 |
/.git/config |
200 β repository config served | 404 |
/recordings/, /war-room/attachments/
|
served | 404 |
Every one of those attachment files is meant to be reached through a PHP endpoint that checks who you are and whether the ticket is yours. Served straight off disk, none of those checks happen.
There is no .htaccess in FreeITSM that nginx can safely be left ignorant of, but they are not equally important. Three shape URLs; nine stop something being served that should not be.
Each row was tested against a real nginx serving a real FreeITSM: a probe file was placed in the directory and requested, once through plain nginx and once through the shipped config.
| File | What it does | Plain nginx | With the config |
|---|---|---|---|
tickets/attachments/ |
denies all + no execute | π΄ 200 β the file is served | β 404 |
change-management/attachments/ |
denies all + no execute | π΄ 200 | β 404 |
war-room/attachments/ |
denies all + no execute | π΄ 200 | β 404 |
contracts/rfp-builder/uploads/ |
denies all | π΄ 200 | β 404 |
recordings/ |
denies all | π΄ 200 | β 404 |
lms/content/ |
no execute (content IS served) | π΄ 200 on a .php
|
β 404 |
system/uploads/branding/ |
no execute (logo IS served) | π΄ 200 on a .php
|
β 404 |
tickets/csat/ |
404s survey.php direct |
π΄ 200 | β 404 |
auth/ |
denies backup/config extensions | β 404 | β 404 |
.htaccess (root) |
pretty URLs, dotfile block, -Indexes
|
.git exposed |
β applied |
api/v1/ |
REST front controller | β applied | |
system/integrations/ |
provider pretty URLs | β applied |
π΄ 200 means the file was served. For an attachment that is a customer's document handed to anyone with the URL, with none of the application's permission checks consulted. For a
.phpin an upload folder it is worse on a real server: the bare test container has no PHP-FPM, so the file came back as source β with PHP-FPM behind it, it executes.
So: is FreeITSM "broken" on nginx? Not any more, and in two separate senses:
-
The application works. Since issue #68 nothing in the code depends on a rewrite. Every redirect names a real file. You can delete every
.htaccessand every rewrite rule and still sign in and use it β that is now covered by a test that runs the whole suite with.htaccessmoved aside. - The security controls are not automatic. They were never broken on nginx so much as absent, which is worse, because absence is silent. That is what the shipped config exists for, and why the verification step in the install section is not optional.
The three
You do not have to take any of this on trust, and you should not have to remember to re-check it. D009 "Guarded paths" asks the questions on this page against your own live install:
- it writes a harmless probe file into each protected folder, asks your own web server for it over HTTP exactly as a stranger would, then deletes it and confirms none were left behind
- it reports each folder as guarded or π΄ exposed, says which web server answered, and explains what that implies
- it names the fix, which differs by what is exposed β an upload folder is a server-configuration problem; an exposed
.gitis a deployment one, and telling you to changeAllowOverridewould not fix it
It deliberately reads no .htaccess files. The question is never whether the file exists, it is whether the server is reading it, and only a real HTTP request answers that.
π It proves it can see your site before it believes a single 404. A tool that could not reach the server would find every folder beautifully "protected" β the most dangerous wrong answer available. So it fetches something that must be served first, and if that fails it reports INCONCLUSIVE and stops, rather than reporting all-clear.
Run it after installing, after any web server change, and after moving host.
-
Copy the config
sudo cp deploy/nginx/freeitsm.conf /etc/nginx/sites-available/freeitsm.conf sudo ln -s /etc/nginx/sites-available/freeitsm.conf /etc/nginx/sites-enabled/
-
Edit the three marked lines β each is commented
# <-- CHANGE ME:Setting What to put server_nameyour hostname, e.g. itsm.example.comrootthe folder containing index.phpfastcgi_passyour PHP-FPM socket or address -
Test and reload
sudo nginx -t && sudo systemctl reload nginx -
Verify the protection is actually on. Do not skip this β it is two commands and it is the difference between a private service desk and a public one. Upload a file to any ticket, then request it directly:
curl -o /dev/null -w '%{http_code}\n' https://itsm.example.com/tickets/attachments/ curl -o /dev/null -w '%{http_code}\n' https://itsm.example.com/.git/config
Both must print
404. A200or a403listing means the config is not being applied β check it is symlinked intosites-enabledand that no earlierserverblock is matching first.
| Section | Replaces | Effect |
|---|---|---|
| Attachment folders β 404 |
Require all denied in five folders |
Attachments, RFP uploads and call recordings are unreachable except through the app |
| Upload folders β no execution |
php_flag engine off, RemoveHandler
|
An uploaded .php is never run. Third defence behind the extension allow-list and storage renaming |
| Dotfile block | the root .htaccess (added at the same time) |
/.git/, .env, editor backups. .well-known stays reachable so Let's Encrypt still works |
survey.php β 404 |
tickets/csat/.htaccess |
The CSAT survey is reachable only through /csat
|
| PHP session flags |
php_value in the root .htaccess
|
Already handled: FreeITSM ships a .user.ini, which is what PHP-FPM reads. Nothing needed in the nginx config -- see the note in the PHP block |
β οΈ The dotfile rule was added because D009 found it live. Until August 2026/.git/configwas served by both Apache and nginx if you had deployed withgit cloneinto your web root, which exposes your commit history and usually the credentials in your remote URL. Both the nginx config and the root.htaccessnow block it. If your install predates that, rotate anything that appears in.git/configβ it has been readable by anyone who asked. Better still, deploy from an export rather than a clone, so there is no.gitto protect.
/login, /logout, /forgot-password, /reset-password, /csat, /a/<token> for asset QR labels, the REST API front controller, and 301s from the old .php URLs for existing bookmarks.
FreeITSM does not depend on any of these. Since issue #68 every internal redirect points at a real file path that resolves with no rewriting at all. Delete the whole pretty-URL section and the application still works β the URLs just get uglier.
That is deliberate. An application should be able to find its own login page without a web-server rule telling it where to look.
The login page lives at auth/login.php. It used to be reachable at exactly one URL, /login, produced by an internal rewrite. Being served from a root-level URL, its relative redirect worked perfectly:
header('Location: index.php'); // at /login -> /index.php β
Once the application began linking to the real path, the same page became reachable at /auth/login.php too. The identical line now resolves one level deeper:
header('Location: index.php'); // at /auth/login.php -> /auth/index.php β 404Signing in succeeded and then landed on a 404. Nothing was wrong with authentication; the redirect simply pointed at a file that does not exist at that depth.
The routing suite tested where signed-out visitors are sent, in both server modes, and passed 6/6. It never tested where a visitor goes after signing in.
That is the whole lesson. The tests covered the half of the journey that the bug report was about, and the untested half was the half a real person reaches first.
If a page can be reached at more than one URL depth, every path it emits must be absolute.
Relative paths silently encode an assumption about how deep the current URL is. That assumption holds right up until somebody adds a second way to reach the page β at which point every relative path in it is wrong, and wrong in a way that only shows up at runtime.
This applies to all four kinds of outbound path, and it was the last three that were missed the first time:
| Example | Fixed to | |
|---|---|---|
| Assets | src="assets/images/CompanyLogo.png" |
BASE_URL . 'assets/β¦' |
| Links | href="forgot-password.php" |
BASE_URL . 'auth/forgot-password.php' |
| PHP redirects | header('Location: index.php') |
BASE_URL . 'index.php' |
| JavaScript redirects | window.location.href = 'index.php' |
BASE_URL . 'index.php' |
The idiom used throughout auth/ tolerates config.php not having loaded:
header('Location: ' . (defined('BASE_URL') ? BASE_URL : '/') . 'index.php');window.location.href = '<?php echo defined('BASE_URL') ? BASE_URL : '/'; ?>index.php';The auth/.htaccess guard that used to 404 direct access existed precisely to stop this β its comment warned about "relative assets resolving against /auth/ and breaking". When that guard was removed, the asset paths it named were fixed and the redirect targets were not, even though they are the same bug in a different disguise.
A comment that names one symptom will be read as naming the whole problem. It named assets; the problem was every outbound path.
tests/routing-login.sh forges an analyst session, asks auth/login.php where it sends a signed-in visitor, and asserts two things:
- the target resolves (200, following with the session cookie), and
- the target is not inside
auth/
Both are checked with and without .htaccess, so a future change cannot fix one server and break the other. 8/8 in both modes.
A footnote on that test: its first run reported a failure that was not real. It followed the redirect without the session cookie, so
index.phpcorrectly bounced it back to login and the check saw a 302. When a new assertion fails, establish whether the code or the assertion is wrong before changing either.
try_files in the PHP block. Debian's snippets/fastcgi-php.conf already contains try_files $fastcgi_script_name =404;. Adding another one in the location ~ \.php$ block is a duplicate-directive error and nginx will refuse to start. The shipped config leaves it out for that reason.
client_max_body_size. nginx defaults to 1 MB and returns 413 on anything larger. FreeITSM accepts attachments up to 20 MB, so the config sets 25 MB. Symptom if you miss it: uploads fail silently in the browser for larger files only.
PHP-FPM version. fastcgi_pass unix:/run/php/php8.2-fpm.sock matches Debian/Ubuntu PHP 8.2. Check ls /run/php/ and adjust.
A server block that matches first. If a default site is enabled, requests may never reach yours. sudo rm /etc/nginx/sites-enabled/default is the usual fix.
The shipped config is syntax-validated (nginx -t) and its security rules are verified behaviourally against a real nginx container serving a real FreeITSM, with a negative control proving that plain nginx does serve the files the rules block.
nginx -t earned its keep immediately: it caught a duplicate try_files that clashes with Debian's snippets/fastcgi-php.conf and would have stopped nginx starting β see Gotchas.
Routing is covered by tests/routing-login.sh, which runs the whole suite twice, once with .htaccess present and once with it moved aside, so a change cannot quietly fix one server and break the other. It checks that the login pages are reachable at their real paths, that a signed-out visitor's redirect from three different URL depths resolves, that the logo loads, and β since the trap above β that a signed-in visitor is sent somewhere that exists and is not inside auth/. 8/8 in both modes.
π΄ What has NOT been tested is a full end-to-end install on nginx with PHP-FPM. The pretty-URL rewrites and the PHP handling are written to match the Apache rules one for one, but no signed-in session has been driven through them. If you run FreeITSM on nginx, please report anything that misbehaves β particularly around the REST API front controller and the OAuth callbacks, which are the fiddliest rules here.
- Root folder tidy β where the auth pages live and why
- Installation β the general install guide
- Security β the wider security model
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)