ML07-0001-0

Chapter 7. Serving Files

HTTPD answers a request that no route claims by sending a file from its document root. This chapter shows where those files are, how they get there, and what HTTPD makes of a request for one: the content type, the directory rules, the path rules and the methods it accepts. The sessions use curl as in Chapter 3, “A First Request”. The Reference describes the same rules as a list. It is the chapter “HTTP Behaviour and Limits” in the HTTPD Reference, and the keywords are in “The HTTPPRM Keywords”.

Where the Files Are

HTTPD does not read files from MVS data sets. It reads them from a UFS file system, which UFSD provides. The Parmlib keyword DOCROOT names the directory in that file system where the files are. The default is /www. HTTPD puts the value in front of the path of the request: a request for /hello.txt reads the file /www/hello.txt.

Message HTTPD001I names the document root when HTTPD is ready (“What the Console Shows”). A request for a file that HTTPD cannot open gets the same answer as a request for a file that is not there (“A File That Is Not There”).

Two parts make the document root:

The keyword DOCROOT has three rules. The value must start with a slash, or HTTPD ignores it without a message and keeps the old value. HTTPD removes one slash at the end of a longer value. HTTPD keeps at most 127 characters. The Reference has the details.

Building a Web Root Image

To publish your own files, build an image on a workstation. The host tool ufsd-utils of UFSD does it in three commands. The first creates an empty image. The second copies a directory tree into it. The third lists what is in it:

$ ufsd-utils create httpd-webroot.img --size 1M --owner IBMUSER --group SYSPROG
Creating httpd-webroot.img (1M, blksize=4096, inodes=10.0%)
  Volume size:     256 blocks (1.00 MB)
  Block size:      4096 bytes
  Inode blocks:    2 (64 inodes)
  Data blocks:     252 (free: 251)
  Root owner:      IBMUSER/SYSPROG
  Format:          UFS370 v1 (time64 timestamps)

  Upload to MVS:  ufsd-utils upload httpd-webroot.img --dsn YOUR.DATASET.NAME
Done.
$ ufsd-utils cp -r static/ httpd-webroot.img:/
  README.txt -> /README.txt (36 bytes, txt)
  mkdir /admin
    report.txt -> /admin/report.txt (50 bytes, txt)
  mkdir /css
    site.css -> /css/site.css (67 bytes, txt)
  data.json -> /data.json (34 bytes, txt)
  mkdir /denied
    data.txt -> /denied/data.txt (50 bytes, txt)
  mkdir /docs
    index.html -> /docs/index.html (131 bytes, txt)
    manual.txt -> /docs/manual.txt (85 bytes, txt)
  mkdir /empty
  mkdir /form
    page.html -> /form/page.html (111 bytes, txt)
  hello.txt -> /hello.txt (61 bytes, txt)
  index.html -> /index.html (3573 bytes, txt)
  logo.png -> /logo.png (70 bytes, bin)
  mkdir /private
    secret.txt -> /private/secret.txt (43 bytes, txt)
  mkdir /pub
    index.html -> /pub/index.html (98 bytes, txt)
    notice.txt -> /pub/notice.txt (23 bytes, txt)
  mkdir /token
    api.txt -> /token/api.txt (38 bytes, txt)
$ ufsd-utils ls -l httpd-webroot.img:/
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 .
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 ..
-rwxr-xr-x   1 IBMUSER  SYSPROG          36 2026 Oct 27 05:42 README.txt
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 admin
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 css
-rwxr-xr-x   1 IBMUSER  SYSPROG          34 2026 Oct 27 05:42 data.json
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 denied
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 docs
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 empty
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 form
-rwxr-xr-x   1 IBMUSER  SYSPROG          61 2026 Oct 27 05:42 hello.txt
-rwxr-xr-x   1 IBMUSER  SYSPROG        3573 2026 Oct 27 05:42 index.html
-rwxr-xr-x   1 IBMUSER  SYSPROG          70 2026 Oct 27 05:42 logo.png
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 private
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 pub
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 token
$ ufsd-utils ls -l httpd-webroot.img:/docs
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 .
drwxr-xr-x   2 IBMUSER  SYSPROG         128 2026 Oct 27 05:42 ..
-rwxr-xr-x   1 IBMUSER  SYSPROG         131 2026 Oct 27 05:42 index.html
-rwxr-xr-x   1 IBMUSER  SYSPROG          85 2026 Oct 27 05:42 manual.txt

Read the output of the first command as follows. The image is 1 MB. It has 256 blocks of 4096 bytes, and 2 of them hold the inode table with room for 64 inodes. --owner and --group set the owner and the group of the root directory. The size and the number of inodes limit how many files and how much data the image holds. Use --size to make it larger, and see the UFSD User’s Guide for the other options.

The output of cp -r has one line for each file and each directory. At the end of each file line the tool says txt or bin. It stores a text file in EBCDIC, in the code page IBM-1047, and a binary file as it is. HTTPD translates a UFS text file back with the same table (“Text, Binary and the Code Page”).

The create command suggests the next step, uploading the image with ufsd-utils upload. The data set that takes the image is a sequential data set with the record format U and a block size of 4096, the block size of the image. The job HTTPWEBR in the sample library allocates such a data set under the name HTTPD.WEBROOT.UFS. The upload must be binary. Chapter 2, “Installing HTTPD” has the whole procedure, with the installation of the image that ships with HTTPD.

Mounting the Image

UFSD mounts the image at the start from its own Parmlib member. The statement for the data set above and the default document root is:

MOUNT    DSN(HTTPD.WEBROOT.UFS)  PATH(/www)  MODE(RO)

MODE(RO) mounts the image for reading. HTTPD only reads. The path in the statement must be the value of DOCROOT, letter for letter. Start UFSD before HTTPD, so that the first request finds the file system. Chapter 2, “Installing HTTPD” shows the installation of the image, and the UFSD User’s Guide describes the Parmlib member and the operator commands. UFSD changes an operator command to upper case before it reads it. A mount point such as /www therefore comes from the Parmlib statement (Chapter 2, “Installing HTTPD”).

To replace the content, stop HTTPD and UFSD, upload the new image to the same data set, and start UFSD, then HTTPD. Chapter 8, “Operating HTTPD” describes stopping and starting.

Changing the Document Root

The document root need not be the root of the mounted file system. If the image has a directory docs, the member below serves that directory as the whole site:

PORT=8080
DOCROOT=/www/docs

After the restart, the path / reads /www/docs/index.html:

$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:51:01 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/html
X-Content-Type-Options: nosniff
Transfer-Encoding: chunked

<!DOCTYPE html>
<html><head><title>Documents</title></head>
<body><h1>Documents</h1>
<p>This is docs/index.html.</p></body></html>

The file /www/docs/manual.txt is now the path /manual.txt:

$ curl -i http://localhost:8080/manual.txt
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:51:01 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 85

HTTPD manual draft
==================
The server serves this file from the UFS root.

A file outside the new root is not found, although it is in the image:

$ curl -i http://localhost:8080/hello.txt
HTTP/1.1 404 Not Found
Date: Tue, 27 Oct 2026 05:51:02 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

404 document "/hello.txt" not found

The file /www/hello.txt exists, but the new document root is /www/docs. A change of DOCROOT takes effect at the next start of HTTPD (Chapter 4, “Configuring HTTPD”).

Directory Requests

A path that ends in a slash names a directory. HTTPD does not list a directory. It looks for the file index.html in it, and if that is not there, for default.html. A directory with an index file answers with the file:

$ curl -i http://localhost:8080/docs/
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/html
X-Content-Type-Options: nosniff
Transfer-Encoding: chunked

<!DOCTYPE html>
<html><head><title>Documents</title></head>
<body><h1>Documents</h1>
<p>This is docs/index.html.</p></body></html>

A directory without index.html and default.html gets a 404. The text repeats the path of the request:

$ curl -i http://localhost:8080/empty/
HTTP/1.1 404 Not Found
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

404 document "/empty/" not found

A directory in a path without the closing slash is not a file, and HTTPD does not add the slash for the client. It does not answer with a redirect:

$ curl -i http://localhost:8080/docs
HTTP/1.1 404 Not Found
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

404 document "/docs" not found

So a link to a directory has to end in a slash. The path /docs gets the answer above, and the path /docs/ gets the index file.

Content Types

HTTPD chooses the Content-Type from the extension of the file name: the text after the last dot in the last part of the path. If that part has no dot, HTTPD looks at the whole path. The comparison ignores case, so LOGO.PNG and logo.png are the same.

ExtensionContent-TypeSent as
html, htmtext/htmltext
csstext/csstext
jsapplication/x-javascripttext
txttext/plaintext
xmltext/xmltext
svgimage/svg+xmltext
pngimage/pngbinary
jpg, jpegimage/jpegbinary
gifimage/gifbinary
icoimage/x-iconbinary
pdfapplication/pdfbinary
zipapplication/zipbinary
any other, or nonetext/plaintext

Table 23. Some content types

The whole table is in “HTTP Behaviour and Limits” in the HTTPD Reference. Three captures show the effect. A style sheet is text and has its type:

$ curl -i http://localhost:8080/css/site.css
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/css
X-Content-Type-Options: nosniff
Content-Length: 67

body { font-family: sans-serif; margin: 2em; }
h1 { color: #036; }

A PNG image has its type, and the body is sent as it is stored. The capture shows only the headers:

$ curl -s -D - -o /dev/null http://localhost:8080/logo.png
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:43 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: image/png
X-Content-Type-Options: nosniff
Content-Length: 70

A file whose extension is not in the table is sent as text/plain, also when the file is JSON:

$ curl -i http://localhost:8080/data.json
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 34

{"name": "httpd", "static": true}
Note: Unknown extensions are text. HTTPD translates every file with an unknown extension as text, and a binary file of that kind arrives damaged. Keep to the extensions in the table for binary files, or rename the file.

Text, Binary and the Code Page

The last column of the table says how HTTPD sends the body.

The keyword CODEPAGE selects the table for the status line, the headers and the text of the responses that HTTPD writes itself, and for the decoding of the request (see “Paths” below). It has no effect on the content of a file. So a text file in the image has to be in IBM-1047. The tool ufsd-utils writes the files of a text type in that code page (“Building a Web Root Image”). The chapter Chapter 4, “Configuring HTTPD” tells how to set CODEPAGE.

Server Side Includes

HTTPD scans a file of the type text/html or text/x-server-parsed-html for server side includes while it sends it. These are the extensions htm, html, shtm and shtml. A directive such as <!--#include virtual="/part.html" --> is replaced by the text it names. Because the final size is not known, the response has no Content-Length and uses chunked transfer, as the response of the welcome page in “Getting the Welcome Page” does. The directives and their limits are in “HTTP Behaviour and Limits” in the HTTPD Reference.

Any other file, also a file with the extension txt, is sent without a scan.

Paths

HTTPD does five things to the path of a request before it joins it to the document root:

The answer for a path that does not name a file is the 404 that the first chapter shows. Its text contains the decoded path:

$ curl -i http://localhost:8080/nothere.html
HTTP/1.1 404 Not Found
Date: Tue, 27 Oct 2026 05:41:42 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

404 document "/nothere.html" not found

What Is Not Supported

A static file is read-only for the client and always whole. HTTPD answers a GET and a HEAD. It does not answer anything else for a file.

HEAD. The curl option -I sends a HEAD request, as “Choosing a Client” shows. HTTPD answers with the status and the headers that a GET gets, and sends no body. A file that is not there gets the 404 of a GET, with no body:

$ curl -I http://localhost:8080/nothere.html
HTTP/1.1 404 Not Found
Date: Tue, 27 Oct 2026 17:11:59 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain

The connection stays open, as it does after a GET (“Two Requests on One Connection”). With curl -v -I and two URLs, the second HEAD uses the connection of the first:

$ curl -v -I http://localhost:8080/hello.txt http://localhost:8080/hello.txt
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
* using HTTP/1.x
> HEAD /hello.txt HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.14.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Tue, 27 Oct 2026 17:11:59 GMT
< Server: HTTPD Server
< X-Powered-By: MVSLOVERS
< Connection: keep-alive
< Content-Type: text/plain
< X-Content-Type-Options: nosniff
< Content-Length: 61
< 
* Connection #0 to host localhost left intact
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 17:11:59 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 61

* Re-using existing http: connection with host localhost
> HEAD /hello.txt HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.14.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Tue, 27 Oct 2026 17:11:59 GMT
< Server: HTTPD Server
< X-Powered-By: MVSLOVERS
< Connection: keep-alive
< Content-Type: text/plain
< X-Content-Type-Options: nosniff
< Content-Length: 61
< 
* Connection #0 to host localhost left intact
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 17:11:59 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 61

A HEAD gets a Content-Length only where a GET gets one. An HTML file has none, because HTTPD looks for server-side includes in it (“Server Side Includes”). A HEAD for such a file has neither a length nor chunked transfer:

$ curl -I http://localhost:8080/docs/
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 17:11:59 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/html
X-Content-Type-Options: nosniff

The HEAD answer of HTTPD 4.4.0 and earlier is 501 Not Implemented.

Other methods. POST, PUT and DELETE for a static file get 501 Not Implemented. The three methods:

$ curl -i -X POST -d x=1 http://localhost:8080/hello.txt
HTTP/1.1 501 Not Implemented
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: close
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

501 Not Implemented
$ curl -i -X PUT -d x=1 http://localhost:8080/hello.txt
HTTP/1.1 501 Not Implemented
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: close
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

501 Not Implemented
$ curl -i -X DELETE http://localhost:8080/hello.txt
HTTP/1.1 501 Not Implemented
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

501 Not Implemented

The responses to POST and PUT have Connection: close because the requests have a body. The response to DELETE, which has none, keeps the connection. A client cannot change a file in the image over HTTP. Replace the image to change the content (“Mounting the Image”).

A method that HTTPD does not know at all gets 405 Method Not Allowed and the connection is closed. The answer has no Allow header. HTTPD sends the 405 before it looks at the route, so it is the same for every path:

$ curl -i -X OPTIONS http://localhost:8080/hello.txt
HTTP/1.1 405 Method Not Allowed
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: close
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

405 Method Not Allowed

Ranges. HTTPD does not look at a Range header. It sends the whole file with status 200 and no Content-Range header:

$ curl -i -H "Range: bytes=0-9" http://localhost:8080/hello.txt
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 61

Hello from the HTTPD document root.
This file is plain text.

Conditional requests. HTTPD does not look at If-Modified-Since either. It never answers with 304, and it sends no Last-Modified or ETag header:

$ curl -i -H "If-Modified-Since: Tue, 27 Oct 2026 00:00:00 GMT" http://localhost:8080/hello.txt
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:41:53 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 61

Hello from the HTTPD document root.
This file is plain text.

A client that sends these headers gets the whole file each time. A server module on a route with a program decides about the methods GET, HEAD, POST, PUT and DELETE and about the headers for its own paths. Chapter 5, “Routes and Server Modules” tells how routes are matched.

Static Locations

A request that no route claims is served from the document root, and nobody needs to log in for it. To ask for a login for a part of the tree, name it in a LOC= route. A LOC= route has no program. HTTPD serves the path as a static file, as in this chapter, after the check of the route has passed. The Parmlib member of the test system has these lines:

LOC=/pub/*                               AUTH=NONE
LOC=/private/*                           AUTH=BASIC
LOC=/admin/*                             AUTH=BASIC RES=FACILITY:HTTPD.ADMIN
LOC=/denied/*                            AUTH=BASIC RES=FACILITY:HTTPD.DENIED

A path under /pub/ is public:

$ curl -i http://localhost:8080/pub/notice.txt
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 05:52:44 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Content-Type: text/plain
X-Content-Type-Options: nosniff
Content-Length: 23

This notice is public.

A path under /private/ asks for a login, and a request without credentials gets a 401 with a WWW-Authenticate header:

$ curl -i http://localhost:8080/private/secret.txt
HTTP/1.1 401 Unauthorized
Date: Tue, 27 Oct 2026 05:52:44 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
WWW-Authenticate: Basic realm="MVS.EXAMPLE"
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

401 Unauthorized

A user who logs in but has no access to the resource of the route gets a 403:

$ curl -i -u IBMUSER:****** http://localhost:8080/denied/data.txt
HTTP/1.1 403 Forbidden
Date: Tue, 27 Oct 2026 05:52:48 GMT
Server: HTTPD Server
X-Powered-By: MVSLOVERS
Connection: keep-alive
Cache-Control: no-store
Content-Type: text/plain
Transfer-Encoding: chunked

403 Forbidden

The syntax of the routes and the order in which HTTPD tries them are in Chapter 5, “Routes and Server Modules”. The modes of AUTH= and the resource check are in Chapter 6, “Authentication and Security”.

When UFSD Is Not There

HTTPD reads static files through UFSD. It creates the control block of its file system when it starts. This step does not contact UFSD, and HTTPD does not check that UFSD runs. The message HTTPD044W UNABLE TO INITIALIZE FILE SYSTEM says that HTTPD could not create that block. It does not say that UFSD is down.

HTTPD asks UFSD for a session when it opens a file, for each request. In each of these cases HTTPD gets no file, and the answer is the 404 of “A File That Is Not There”:

The Parmlib keyword UFS=0 switches the file system off. HTTPD then serves no file, and every path that no route claims gets a 404. Leave the keyword at its default if you want to serve files. A route with a program is dispatched without the file system.

When every static request gets a 404, check in this order: that UFSD runs, that the image is mounted (the operator command F UFSD,MOUNT LIST shows the mounts), that the mount point is the value of DOCROOT, and that the file is in the image. See also Chapter 12, “Troubleshooting”.

What Can Go Wrong