NGINX as a file server

Published On March 21, 2017

category server | tags nginx

NGINX is good at serviing static files such as images and html files. It means it's already a great server for downloading. In this article I expand it by adding features of authentication, uploading and deleting files using lua. I will also talk about the community favorite nginx-upload-module at the end of this article.


When we send a get request, NGINX searches for a file by appending URI to the path specified by root. If the URI ends with a slash, NGINX treats it as a directory and tries to find an index file which is index.html by default in it. If such a file can not be found, NGINX returns HTTP code 403(Forbidden). The ngx_http_autoindex_module can return an automatically generated directory listing in this case.

server {
    listen       8001;          # a customed port

    # download
    autoindex on;               # enable directory listing output
    autoindex_exact_size off;   # output file sizes rounded to kilobytes, megabytes, and gigabytes
    autoindex_localtime on;     # output local times in the directory

    location / {
        root upload;

upload is a folder under NGINX's prefix which is /opt/nginx in my case:

├── 1.txt
├── Dog and cat.mp4
└── sub
    └── 2.txt

If you use a modern browser, you can directly preview many file types such as videos, pdfs, etc. Like the built-in autoindex module, aperezdc/ngx-fancyindex is a fancier alternative of autoindexing by adding customized theme.

Notice: Executable permissions of the listing directory are required besides read permissions.

location / {
    root upload;
is equivalent to:
location /download {
    alias upload;
See Nginx — static file serving confusion with root & alias


The ngx_http_auth_basic_module allows limiting access to resources by validating the user name and password using "HTTP Basic Authentication" protocol.

server {
    # auth
    auth_basic "Restricted site";
    auth_basic_user_file /opt/nginx/.htpasswd;
Only two directives are provided by this module.


enables authentication and the string paramter is used as realm. Some browers can display this message in the prompt.


is the file that keeps user names and hashed passwords. Most directives which need a path such as root, access_log can take a relative path(relative to nginx's prefix) as argument . But auth_basic_user_file must be an absolute path otherwith you will see "403 Forbidden" error page.

Let's generate this file by OpenSSL utilities which may already be available on most servers:

echo -n 'foo:' >> .htpasswd
openssl passwd >> .htpasswd
# type your password twice

cat .htpasswd

add another user bar:

echo -n 'bar:' >> .htpasswd
openssl passwd -apr1 >> .htpasswd
# type your password twice
cat .htpasswd
-apr1 means the password is hashed with the Apache variant of the MD5-based password algorithm which is more secure than the default crypt algorithm.

Notice that the encrypted password will be different each time even if you use an identical password. It's magical, isn't it? Because a unique salt is chosen each time. The first 2 characters of crypt's output is salt and in the second case the output format is $apr1$salt$hash. If you specify salt by using -salt option you will always get the same result.

Now refresh the page, user will be asked for username and password. If you enter the correct credentials, you will be allowed to access these locations. Otherwith, you will see "401 Authorization Required" error page.


I know it's not the best way of handling file upload, but NGINX is versatile enough to achieve this without any backend support. Until now, all the features above are provided by nginx itself. Making NGINX support uploading needs help from NGINX 3rd Party Modules.


Here I use openresty/lua-resty-upload which is a lua module based on ngx_lua cosocket. It requires lua-nginx-module to be compiled into NGINX.

lua-resty-upload contains only one file upload.lua. Place this file to /usr/local/lib/lua/5.1/resty/upload.lua and then add it to ngx_lua's LUA_PATH search path by directive lua_package_path.

lua_package_path '/usr/local/lib/lua/5.1/?.lua;;';

This module is so simple that user just needs to call the read method chunk by chunk. Please refer it’s readme documentation for details about this api.


Bellow is the full configuration:

pid        logs/;
events {
    worker_connections  1024;

http {
    lua_package_path '/usr/local/lib/lua/5.1/?.lua;;';

    server {
        listen       8001;

        # download
        autoindex on;
        autoindex_exact_size off;
        autoindex_localtime on;

        # auth
        auth_basic "Restricted site";
        auth_basic_user_file /opt/nginx/.htpasswd;

        location /download {
            alias upload;

        location ~ ^/upload_lua(/.*)?$ {
            set $store_path upload$1/;
            content_by_lua_file conf/lua/my_upload.lua;

        location ~ ^/delete/(.*)$ {
            set $file_path upload/$1;
            content_by_lua_file conf/lua/my_delete.lua;


local upload = require "resty.upload"

local function my_get_file_name(header)
    local file_name
    for i, ele in ipairs(header) do
        file_name = string.match(ele, 'filename="(.*)"')
        if file_name and file_name ~= '' then
            return file_name
    return nil

local chunk_size = 4096
local form = upload:new(chunk_size)
local file
local file_path
while true do
    local typ, res, err = form:read()

    if not typ then
         ngx.say("failed to read: ", err)

    if typ == "header" then
        local file_name = my_get_file_name(res)
        if file_name then
            file_path = ngx.var.store_path..file_name
            file =, "w+")
            if not file then
                ngx.say("failed to open file ", file_path)

    elseif typ == "body" then
        if file then

    elseif typ == "part_end" then
        if file then
            file = nil
            ngx.say("upload to "..file_path.." successfully!")
    elseif typ == "eof" then

        -- do nothing

Every time a multipart/form-data form field is encouterred a file is created using the original file name if the header of that field contains filename attribute.


I also add a location which enables user to delete a file or an empty folder.

local function file_exists(path)
  local file =, "rb")
  if file then file:close() end
  return file ~= nil
if not file_exists(ngx.var.file_path) then
    ngx.say("file not found: "..ngx.var.file_path)
r, err = os.remove(ngx.var.file_path)
if not r then
    ngx.say("failed to delete: "..err)
    ngx.say("delete successfully!")


❯ curl -H "Authorization: Basic Zm9vOjEyMzQ1Ng==" -F filea=@a.txt -F fileb=@b.txt    
upload to upload/a.txt successfully!
upload to upload/b.txt successfully!

I also set a variable store_path to denote the path where this file should be saved. Including the part after /upload_lua/ of URI in this path enables user to control in which sub folder to save files. But such sub folders should already exist or error will occur.

❯ curl -H "Authorization: Basic Zm9vOjEyMzQ1Ng==" -F filea=@a.txt -F fileb=@b.txt  
upload to upload/sub/a.txt successfully!
upload to upload/sub/b.txt successfully!

I prefer cURL to Postman for debugging. Postman can't display the full request body of Content-Type multipart/form-data for now. cURL with --trace-ascii option can dump all incoming and outgoing data flow although it's a little hard to read. Here is an example:

❯ curl -H "Authorization: Basic Zm9vOjEyMzQ1Ng==" -F filea=@a.txt --trace-ascii -      
== Info:   Trying
== Info: Connected to ( port 8001 (#0)
=> Send header, 263 bytes (0x107)
0000: POST /upload_lua HTTP/1.1
001b: Host:
0037: User-Agent: curl/7.49.1
0050: Accept: */*
005d: Authorization: Basic Zm9vOjEyMzQ1Ng==
0084: Content-Length: 191
0099: Expect: 100-continue
00af: Content-Type: multipart/form-data; boundary=--------------------
00ef: ----2ccbb2d137903ef2
<= Recv header, 23 bytes (0x17)
0000: HTTP/1.1 100 Continue
=> Send data, 136 bytes (0x88)
0000: --------------------------2ccbb2d137903ef2
002c: Content-Disposition: form-data; name="filea"; filename="a.txt"
006c: Content-Type: text/plain
=> Send data, 7 bytes (0x7)
0000: hi yxr!
=> Send data, 48 bytes (0x30)
0002: --------------------------2ccbb2d137903ef2--
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 22 bytes (0x16)
0000: Server: nginx/1.11.8
<= Recv header, 37 bytes (0x25)
0000: Date: Tue, 21 Mar 2017 03:15:05 GMT
<= Recv header, 40 bytes (0x28)
0000: Content-Type: application/octet-stream
<= Recv header, 28 bytes (0x1c)
0000: Transfer-Encoding: chunked
<= Recv header, 24 bytes (0x18)
0000: Connection: keep-alive
<= Recv header, 2 bytes (0x2)
<= Recv data, 48 bytes (0x30)
0000: 25
0004: upload to upload/a.txt successfully!.
002b: 0
upload to upload/a.txt successfully!
== Info: Connection #0 to host left intact
As you can see the content of file a.txt is hi yxr!.

Deleting a file is as simple as:

❯ curl --user foo:123456
delete successfully!

Now we have set up a simple file server using NINGX.


Actually I seek help from nginx-upload-module at first. It was an awesome module. However, the owner is no longer maintaining this module.

Below is what I tried with this module which may be helpful in case you want to play with it yourself.

You should download branch 2.2 as it contains the newest commits.

There is a compile error: md5.h: No such file or directory because NIGNX uses internal MD5 and SHA implementations and further the internal ngx_md5.h is different from openssl's md5.h. Here is a simple patch which fixes this problem: fix-md5.h-No-such-file-or-directory

If you see error simimlar to undefined reference to 'MD5_Upddate' when compiling NGINX you may need to add --with-ld-opt='-lssl -lcrypto':

./configure --prefix=/opt/nginx \
--with-ld-opt='-lssl -lcrypto' \

common usage

This module is typlically used to handle file uploads without passing them to backend server. Here is a simple example from this module with slight modification.

# upload
client_max_body_size 100m;
# Upload form should be submitted to this location
location /upload {
    # Pass altered request body to this location
    upload_pass /example.php;
    # 开启resumable
    upload_resumable on;
    # Store files to this directory
    # The directory is hashed, subdirectories 0 1 2 3 4 5 6 7 8 9 should exist
    upload_store /tmp/upload 1;
    upload_state_store /tmp/state;

    # Allow uploaded files to be read only by user
    upload_store_access user:r;

    # Set specified fields in request body
    upload_set_form_field "${upload_field_name}_name" $upload_file_name;
    upload_set_form_field "${upload_field_name}_content_type" $upload_content_type;
    upload_set_form_field "${upload_field_name}_path" $upload_tmp_path;

    # Inform backend about hash and size of a file
    upload_aggregate_form_field "${upload_field_name}_md5" $upload_file_md5;
    upload_aggregate_form_field "${upload_field_name}_size" $upload_file_size;

    upload_pass_form_field "^submit$|^description$";
location ~ \.php$ {
   # fastcgi_pass   unix:/run/php-fpm/php-fpm.sock;
   fastcgi_index  index.php;
   # fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
   fastcgi_param   SCRIPT_FILENAME    $document_root$fastcgi_script_name;

   include        fastcgi_params;

example.php should be placed in html folder:

$header_prefix = 'file';
$slots = 6;
<title>Test upload</title>
if ($_POST){
    echo "<h2>Uploaded files:</h2>";
    echo "<table border=\"2\" cellpadding=\"2\">";

    echo "<tr><td>Name</td><td>Location</td><td>Content type</td><td>MD5</td><td>Size</tr>";

    for ($i=1;$i<=$slots;$i++){
        $key = $header_prefix.$i;
        if (array_key_exists($key."_name", $_POST) && array_key_exists($key."_path",$_POST)) {
            $tmp_name = $_POST[$key."_path"];
            $name = $_POST[$key."_name"];
            $content_type = $_POST[$key."_content_type"];
            $md5 = $_POST[$key."_md5"];
            $size = $_POST[$key."_size"];

            echo "<tr><td>$name</td><td>$tmp_name</td><td>$content_type</td><td>$md5</td><td>$size</td>";

    echo "</table>";

<h2>Select files to upload</h2>
<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
<input type="file" name="file1"><br>
<input type="file" name="file2"><br>
<input type="file" name="file3"><br>
<input type="file" name="file4"><br>
<input type="file" name="file5"><br>
<input type="file" name="file6"><br>
<input type="submit" name="submit" value="Upload">
<input type="hidden" name="test" value="value">
upload1 upload2

Files are stored under directory specified by upload_store directive using a simple hash algorithm. For example upload_store upload 1 2 may result the file saved as /opt/nginx/upload/1/05/0000000018. Subdirectories 1 and 05 are both randomly selected and should exist before uploading. Hashed subdirectory is optional. File is renamed to a 10 digits to avoid conflict in case a file with the same name exists.

Fortunately, upload_store can also accept variables.

Hack it as a normal file server

What if I want to save the file as its original file name? Let's hack it.

This module implements a mechanism of resumable file upload aka partial upload. It means a big file can be splitted into severl segments and then uploaded one by one in seperate Post requests. Client is responsible for choosing an unique Session-ID which is an identifier of a file being uploaded as well as the name of file saved in server.

Yes, I can achieve my goal by using the file name as Session-ID. Apply this patch to allow Session-ID to contain dot. hack-allow-session-id-to-contain-dot

configuration now looks like this:

# Upload form should be submitted to this location
location ~ ^/upload_mod(/.*)?$ {
    # Pass altered request body to this location
    upload_pass /upload_return;
    # 开启resumable
    upload_resumable on;

    upload_store upload$1;
    upload_state_store /tmp/state;
location /upload_return {
    return 200 ok;

test file upload, Content-Type and X-Content-Range are omitted:

❯ curl -X POST --user foo:123456 -H "Content-Disposition: attachment, filename=\"a.txt\"" -H "X-Session-ID: a.txt" --data-binary @a.txt ""
File a.txt is uploaded successfully:
❯ cat a.txt                                          
hi yxr!
❯ curl --user foo:123456 ""   
hi yxr!

resumable file uploads

❯ curl -X POST --user foo:123456 -H "Content-Type: text/plain" -H "Content-Disposition: attachment, filename=\"a.txt\"" -H "X-Session-ID: a.txt" -H "X-Content-Range: bytes 0-4/10" --data-binary hello ""

❯ curl -X POST --user foo:123456 -H "Content-Type: text/plain" -H "Content-Disposition: attachment, filename=\"a.txt\"" -H "X-Session-ID: a.txt" -H "X-Content-Range: bytes 5-9/10" --data-binary world ""

❯ curl --user foo:123456 ""


qq email facebook github
© 2018 - Xurui Yan. All rights reserved
Built using pelican