Express Admin

Express Admin is a NodeJS tool for easy creation of user friendly administrative interface for MySQL, MariaDB, SQLite and PostgreSQL databases.

It's built with: Hogan.js (mustache.js), Express, mysql and Bootstrap.

The configuration is done through hand editing json files, although in the future there might be some kind of a GUI tool for that.

Features

Resources



Install

Get the module

$ [sudo] npm install [-g] express-admin
# PostgreSQL only: run this inside the express-admin folder
$ npm install pg@2.8.2
# SQLite only: run this inside the express-admin folder
$ npm install sqlite3@2.2.0

Create a project

Depending on how you installed it, creating a new project is as simple as

$ admin path/to/config/dir
# or
$ node path/to/express-admin/app.js path/to/config/dir

Either way the path to the configuration directory should exist. If it doesn't contains any configuration files yet, you'll be prompted to add required information for your database, server port and admin account credentials.

After that you can navigate to http://localhost:[specified port] and see your admin up and running.

The next step is to configure it.



Configuration

config.json

The application's configuration is stored inside the config.json file.

{
    "mysql": { // or "pg" or "sqlite"
        "database": "express-admin-examples",
        "user": "liolio",
        "password": "karamba"
        // "schema": "schema-name"
    },
    "server": {
        "port": 3000
    },
    "app": {
        "layouts": true,
        "themes": true,
        "languages": true,
        "root": "/admin",
        "upload": "/upload/folder"
    }
}



Configuration

settings.json

All settings related to the default Express Admin views are set inside the settings.json file, which is automatically generated with default values at first start up.

"table_name": {
    "slug": "unique-slug",
    "table": {
        "name": "table_name",
        "pk": "pk_name",
        "verbose": "Verbose Name"
        // "schema": "name" // pg: set specific schema for this table only
    },
    "columns": [
        {...}, // see column definition below
        // { "manyToMany" ... } // see 'Many to Many' documentation
    ],
    "mainview": {
        "show": true
    },
    "listview": {
        "order": {
            "column_name1": "asc",
            "column_name2": "desc"
        },
        "page": 25,
        "filter": ["column_name1", "column_name2" ...]
    },
    "editview": {
        "readonly": false,
        // "manyToOne": { ... }, // see 'Many to One' documentation
        // "oneToOne": { ... } // see 'One to One' documentation
    }
}

The settings.json file contains a list of objects representing the database's tables. Each table object have:

Column

Each table object contains an array of colums. Each column object have:

{
    "verbose": "Verbose Name",
    "name": "column_name",
    "control": {
        "text": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "defaultValue": null,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    // "oneToMany": { ... }, // see 'One to Many' documentation
}



Configuration

custom.json

All objects in this file are user created. The public key is related entirely to inclusion of custom static files. It's possible to have an object containing only public key in it, as well as object containing an app key only, or both.

"unique-key-here": {
    "app": {
        "path": "/absolute/path/to/custom/app.js",
        "slug": "unique-slug",
        "verbose": "Verbose Name",
        "mainview": {
            "show": true
        }
    },
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/public/dir",
            "css": [
                "/relative/to/above/global.css"
            ],
            "js": [
                "/relative/to/above/global.js"
            ]
        },
        "external": {
            "css": [
                "//absolute/url/external.css"
            ],
            "js": [
                "//absolute/url/external.js"
            ]
        }
    },
    "events": "/absolute/path/to/custom/events.js"
}

See the custom views documentation and the examples



Relationships

One to Many

One to Many

  1. Inside the settings.json file, find the table you are looking for
  2. Inside that table's columns array find the foreign key column you want to use for the relation
  3. Inside that column's object insert oneToMany key
  4. Change the column's control type to select
"control": {
    "select": true
},
"oneToMany": {
    "table": "user",
    "pk": "id",
    "columns": [
        "firstname",
        "lastname"
    ]
}

The oneToMany key additionally can contain a schema key, specifying PostgreSQL schema name for the relation table



Relationships

Many to Many

Many to Many

  1. Inside the settings.json file, find the table you are looking for
  2. Add the object from below inside that table's columns array
{
    "verbose": "Recipe Types",
    "name": "recipe_type",
    "control": {
        "select": true,
        "multiple": true
    },
    "type": "int(11)",
    "allowNull": false,
    "listview": {
        "show": false
    },
    "editview": {
        "show": true
    },
    "manyToMany": {
        "link": {
            "table": "recipe_has_recipe_types",
            "parentPk": "recipe_id",
            "childPk": "recipe_type_id"
        },
        "ref": {
            "table": "recipe_type",
            "pk": "id",
            "columns": [
                "title"
            ]
        }
    }
}

The link and the ref keys additionally can contain a schema key, specifying PostgreSQL schema name for the relation table

The link table's parentPk and childPk key can be array as well.
The ref table's pk key can be array as well.
See compound primary key documentation.



Relationships

Many to One

Many to One

  1. Inside the settings.json file, find the table you are looking for
  2. Inside that table's editview key add a manyToOne key
"manyToOne": {
    "repair": "car_id",
    "driver": "car_id"
}



Relationships

One to One

One to One

  1. Inside the settings.json file, find the table you are looking for
  2. Inside that table's editview key add a oneToOne key
"oneToOne": {
    "address": "user_id",
    "phone": "user_id"
}



Compound Primary Key

Compound Primary Key

Inside the settings.json file each table can be configured to have multiple primary keys through its pk field (set automatically on project's first run)

"table": {
    "name": "tbl",
    "pk": [
        "id1",
        "id2"
    ],
    "verbose": "tbl"
}

Compound One to Many

Compound One to Many

In case One to Many table relationship is referenced by multiple foreign keys, the regular One to Many setting can't be used, as it expects to be put inside an existing column inside the settings.json file.

Therefore the following fake column entry must be added to the columns array (similar to how Many to Many relationship is configured)

The fk key specifies the foreign keys in this table that are referencing the other one.

{
    "verbose": "otm",
    "name": "otm",
    "control": {
        "select": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    "fk": [
        "otm_id1",
        "otm_id2"
    ],
    "oneToMany": {
        "table": "otm",
        "pk": [
            "id1",
            "id2"
        ],
        "columns": [
            "name"
        ]
    }
}

Compound Many to Many

Compound Many to Many

In case tables with multiple primary keys are part of a Many to Many table relationship, the regular Many to Many setting is used, but additionally the parentPk and childPk keys inside the link table, and the pk key inside the ref table, can be set to array of foreign and primary keys respectively to accommodate that design.

{
    "verbose": "mtm",
    "name": "mtm",
    "control": {
        "select": true,
        "multiple": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    "manyToMany": {
        "link": {
            "table": "tbl_has_mtm",
            "parentPk": [
                "tbl_id1",
                "tbl_id2"
            ],
            "childPk": [
                "mtm_id1",
                "mtm_id2"
            ]
        },
        "ref": {
            "table": "mtm",
            "pk": [
                "id1",
                "id2"
            ],
            "columns": [
                "name"
            ]
        }
    }
}

Compound Many to One

Compound Many to One

Same as the regular Many to One setting, but additionally the value for each table listed there can be set to array of foreign keys referencing this table.

"manyToOne": {
    "mto": [
        "tbl_id1",
        "tbl_id2"
    ]
}

Compound One to One

Same as the regular one to One setting, but additionally the value for each table listed there can be set to array of foreign keys referencing this table.

"oneToOne": {
    "oto": [
        "tbl_id1",
        "tbl_id2"
    ]
}



Editors

CKEditor

Install

You should either download CKEditor from ckeditor.com or use it directly from cdnjs.com //cdnjs.cloudflare.com/ajax/libs/ckeditor/4.4.1/ckeditor.js

settings.json

Inside the settings.json file, find the column you are looking for, and add an additional editor property to its control type key, specifying the class name to use for this instance.

"control": {
    "textarea": true,
    "editor": "class-name"
}

custom.json

Inside the custom.json file add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/ckeditor/ckeditor.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}

my-custom.js

Your custom editor is initialized here. It's just plain javascript/jquery code, and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof CKEDITOR !== 'undefined') {
        CKEDITOR.replaceAll(function (textarea, config) {
            // exclude textareas that are inside hidden inline rows
            if ($(textarea).parents('tr').hasClass('blank')) return false;
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('class-name') != -1) return true;
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }
});

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof CKEDITOR !== 'undefined') {
        // for each of the new rows containing textareas
        $('textarea', rows).each(function (index) {
            // get the DOM instance
            var textarea = $(this)[0];
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('class-name') != -1) return CKEDITOR.replace(textarea);
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }
}

Here class-name is the same class name you specified inside the settings.json file for this column.

The CKEDITOR.replaceAll method loops throgh all textareas on the page and filter out only to those that needs to be initialized as ckeditors. The most important bit is that you should always exclude the textareas that are contained inside the hidden row for an inline record. That's easy because all of them have a blank class on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we loop through all of them and initialize only those textareas that have the class we specified in settings.json



Editors

TinyMCE

Install

You should either download TinyMCE from tinymce.com or use it directly from cdnjs.com //cdnjs.cloudflare.com/ajax/libs/tinymce/4.1.4/tinymce.min.js

settings.json

Inside the settings.json file, find the column you are looking for, and add an additional editor property to its control type key, specifying the class name to use for this instance.

"control": {
    "textarea": true,
    "editor": "class-name"
}

custom.json

Inside the custom.json file add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/tiny_mce.js",
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/jquery.tinymce.min.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}

my-custom.js

Your custom editor is initialized here. It's just plain javascript/jquery code, and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof tinyMCE !== 'undefined') {
        // it's important to initialize only the visible textareas
        $('tr:not(.blank) .class-name').tinymce({});
    }
});

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof tinyMCE !== 'undefined') {
        // init tinymce editors
        $('.class-name', rows).tinymce({});
    }
}

Here class-name is the same class name you specified inside the settings.json file for this column.

The tr:not(.blank) .tinymce selector filters out only to those textareas that have the class-name we specified in settings.json, but not contained inside a hidden row. The most important bit is that you should always exclude the textareas that are contained inside the hidden row for an inline record. That's easy because all of them have a blank class on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we loop through all of them and initialize only those textareas that have the class we specified in settings.json



Editors

Multiple

Install

You should either download CKEditor and TinyMCE from their official site ckedtor.com and tinymce.com or use them directly from cdnjs.com

settings.json

Inside the settings.json file, find the columns you are looking for, and add an additional editor property to each column's control type key, specifying the class name to use for this instance.

"control": {
    "textarea": true,
    "editor": "ck-full"
}
"control": {
    "textarea": true,
    "editor": "ck-compact"
}
"control": {
    "textarea": true,
    "editor": "tinymce"
}

custom.json

Inside the custom.json file add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "external": {
            "js": [
                "//cdnjs.cloudflare.com/ajax/libs/ckeditor/4.4.1/ckeditor.js"
            ]
        },
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/tiny_mce.js",
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/jquery.tinymce.min.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}

my-custom.js

Your custom editors are initialized here. It's just plain javascript/jquery code, and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof CKEDITOR !== 'undefined') {
        CKEDITOR.replaceAll(function (textarea, config) {
            // exclude textareas that are inside hidden inline rows
            if ($(textarea).parents('tr').hasClass('blank')) return false;
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('ck-full') != -1) return true;
            // textareas with this class name will have custom configuration
            if (textarea.className.indexOf('ck-compact') != -1)
                return setCustomConfig(config);
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }

    if (typeof tinyMCE !== 'undefined') {
        // it's important to initialize only the visible textareas
        $('tr:not(.blank) .tinymce').tinymce({});
    }
});

// ckeditor only
function setCustomConfig (config) {
    config = config || {};
    // toolbar
    config.toolbarGroups = [
        { name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },
        { name: 'paragraph',   groups: [ 'list', 'indent', 'blocks', 'align' ] },
        '/',
        { name: 'styles' },
        { name: 'colors' }
    ];
    config.removeButtons = 'Smiley,SpecialChar,PageBreak,Iframe,CreateDiv';
    return config;
}

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof CKEDITOR !== 'undefined') {
        // for each of the new rows containing textareas
        $('textarea', rows).each(function (index) {
            // get the DOM instance
            var textarea = $(this)[0];
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('ck-full') != -1) return CKEDITOR.replace(textarea);
            // textareas with this class name will have custom configuration
            if (textarea.className.indexOf('ck-compact') != -1)
                return CKEDITOR.replace(textarea, setCustomConfig());
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }

    if (typeof tinyMCE !== 'undefined') {
        // init tinymce editors
        $('.tinymce', rows).tinymce({});
    }
}

Here based on a specific class-name specified for each column inside the settings.json file, the corresponding textarea is initialized accordingly.

The CKEDITOR.replaceAll method loops throgh all textareas on the page and filter out only to those that needs to be initialized as ckeditors. The most important bit is that you should always exclude the textareas that are contained inside the hidden row for an inline record. That's easy because all of them have a blank class on their containing row.

The tr:not(.blank) .tinymce selector filters out only to those textareas that have the class-name we specified in settings.json, but not contained inside a hidden row. The most important bit is that you should always exclude the textareas that are contained inside the hidden row for an inline record. That's easy because all of them have a blank class on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we loop through all of them and initialize only those textareas that have the class we specified in settings.json



Custom views (apps)

The admin's custom views follow the Modular web applications with Node.js and Express approach presented by TJ Holowaychuk.

The only difference here is that we're dealing with mustache templates.

custom.json

"view1": {
    "app": {
        "path": "/absolute/path/to/custom/app.js",
        "slug": "view1",
        "verbose": "My Custom View",
        "mainview": {
            "show": true
        }
    }
}

app.js

var express = require('express');
var app = module.exports = express();
var path = require('path');

// set your custom views path
app.set('views', path.join(__dirname, 'views'));

app.get('/view1', function (req, res, next) {

    // create a realtive from the admin's view folder to your custom folder
    var relative = path.relative(res.locals._admin.views, app.get('views'));

    res.locals.partials = {

        // set the path to your templates like this
        // the content partial is declared inside the admin's base.html
        // this is the entry point for all your custom stuff
        content: path.join(relative, 'template')
    };

    // typically you want your stuff to be rendered inside the admin's UI
    // so no need to render here
    next();
});

You should definitely run the examples from the examples repository as each of the custom views (apps) presented there is really well commented.

Route wide variables

Several variables are exposed to every route callback.

// _admin's variable content is for internal purposes only and is not being rendered
res.locals._admin.db.connection// the admin's database connection can be reused from here
res.locals._admin.config       // the contents of the config.json file
res.locals._admin.settings     // the contents of the settings.json file
res.locals._admin.custom       // the contents of the custom.json file
res.locals._admin.users        // the contents of the users.json file

// shortcut variables that are being rendered
res.locals.libs       // list of all client side libraries
res.locals.themes     // list of all themes
res.locals.layouts    // flag indicating layouts button visibility
res.locals.languages  // list of all languages

// holds the absolute path to the admin's view directory
res.locals._admin.views



Events

Currently the supported event hooks are:

custom.json

"some-key": {
    "events": "/absolute/path/to/custom/events.js"
}

events.js

Run the admin with the --debug-brk flag and use node-inspector to drill down into the event hook parameters

exports.preSave = function (req, res, args, next) {
    if (args.debug) console.log('preSave');
    debugger;
    next();
}
exports.postSave = function (req, res, args, next) {
    if (args.debug) console.log('postSave');
    debugger;
    next();
}

Take a look at the event examples here and here

preSave args

examples

created_at / updated_at
var moment = require('moment');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'user') {
        var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss'),
            record = args.data.view.user.records[0].columns;
        if (args.action == 'insert') {
            record.created_at = now;
            record.updated_at = now;
        }
        else if (args.action == 'update') {
            record.updated_at = now;
        }
    }
    next();
}
generate hash id
var shortid = require('shortid');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'car') {
        if (args.action == 'insert') {
            var table = args.name,
                record = args.data.view[table].records[0].columns;
            record.id = shortid.generate();
        }
        for (var table in args.data.manyToOne) {
            var inline = args.data.manyToOne[table];
            if (!inline.records) continue;
            for (var i=0; i < inline.records.length; i++) {
                if (inline.records[i].insert != 'true') continue;
                inline.records[i].columns.id = shortid.generate();
            }
        }
    }
    next();
}
soft delete records
var moment = require('moment');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'purchase') {
        var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss');
        // all inline oneToOne and manyToOne records should be marked as deleted
        for (var table in args.data.manyToOne) {
            var inline = args.data.manyToOne[table];
            if (!inline.records) continue;
            for (var i=0; i < inline.records.length; i++) {
                if (args.action != 'remove' && !inline.records[i].remove) continue;
                // instead of deleting the record
                delete inline.records[i].remove;
                // update it
                inline.records[i].columns.deleted = true;
                inline.records[i].columns.deleted_at = now;
            }
        }
        // parent record
        if (args.action == 'remove') {
            // instead of deleting the record
            args.action = 'update';
            // update it
            var record = args.data.view.purchase.records[0].columns;
            record.deleted = true;
            record.deleted_at = now;
        }
    }
    next();
}

postSave args

examples

upload files to a third party server
var cloudinary = require('cloudinary'),
    fs = require('fs'),
    path = require('path');
cloudinary.config({
    cloud_name: '',
    api_key: '',
    api_secret: ''
});
exports.postSave = function (req, res, args, next) {
    if (args.name == 'item') {
        // file upload control data
        var image = args.upload.view.item.records[0].columns.image;
        // in case file is chosen through the file input control
        if (image.name) {
            // file name of the image already uploaded to the upload folder
            var fname = args.data.view.item.records[0].columns.image;
            // upload
            var fpath = path.join(args.upath, fname);
            cloudinary.uploader.upload(fpath, function (result) {
                console.log(result);
                next();
            });
        }
        else next();
    }
    else next();
}

preList args

examples

soft deleted records
exports.preList = function (req, res, args, next) {
    if (args.name == 'purchase') {
        // check if we're using listview's filter
        // and actually want to see soft deleted records
        var filter = args.filter.columns;
        if (filter && (filter.deleted=='1' || filter.deleted_at && filter.deleted_at[0])) {
            return next();
        }
        // otherwise hide the soft deleted records by default
        var filter = 
            ' `purchase`.`deleted` IS NULL OR `purchase`.`deleted` = 0' +
            ' OR `purchase`.`deleted_at` IS NULL ';
        args.statements.where
            ? args.statements.where += ' AND ' + filter
            : args.statements.where = ' WHERE ' + filter
    }
    next();
}



Embedding

Inside the config.json file, under the app key, include an additional root key, specifying the root mount point for your embedded Express Admin application.

config.json

"app": {
    ...
    "root": "/some-path"
}

Example

In this example Express Admin will be located under the /admin path of your app.

var express = require('express'),
    xAdmin = require('express-admin');

var config = {
    dpath: './express-admin-config/',
    config: require('./express-admin-config/config.json'),
    settings: require('./express-admin-config/settings.json'),
    custom: require('./express-admin-config/custom.json'),
    users: require('./express-admin-config/users.json')
    // additionally you can pass your own session middleware to use
    session: session({...})
};

xAdmin.init(config, function (err, admin) {
    if (err) return console.log(err);
    // web site
    var app = express();
    // mount express-admin before any other middlewares
    app.use('/admin', admin);
    // site specific middlewares
    app.use(express.bodyParser());
    // site routes
    app.get('/', function (req, res) {
        res.send('Hello World');
    });
    // site server
    app.listen(3000, function () {
        console.log('My awesome site listening on port 3000');
    });
});



Themes

Use the Themes button to switch between the themes.



Tools

Shell

Debugging

Set a breakpoint somewhere inside your custom view files and debug as usual.

$ admin --debug-brk path/to/project/config/
# or
$ node --debug-brk path/to/express-admin/app path/to/project/config/

Supervisor

Auto reload your project while configuring it using supervisior

Create a folder for your project's configuration files.

$ mkdir project

Then while you're still there you can use supervisor to watch your project folder, specifying that the extensions are json

$ supervisor -w project -e json -- path/to/express-admin/app -v project/

The -v flag that you're passing to the Express Admin app means that you are running the admin in development mode. In this mode the authentication and the database record updates are disabled.

Additionally you can pass the -l flag as well, which will log out all sql queries that Express Admin generates.

Forever

Run an admin instance continuously in the background using forever

$ forever start path/to/express-admin/app path/to/project/config/



Tools

NginX

You can serve all your static files with NginX

server {
    listen 80;

    # vhost
    server_name admin.example.com;

    #log
    access_log /var/log/nginx/admin-access.log;
    error_log /var/log/nginx/admin-error.log debug;

    # NODE
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # NGINX

    # static
    location ~ \.(jpg|jpeg|png|gif|swf|flv|mp4|mov|avi|wmv|m4v|mkv|ico|css|js|txt|html|htm)$ {
        root /;
        try_files
            # express admin
            /absolute/path/to/express-admin/public/$uri

            # custom views
            /absolute/path/to/your/project/custom/app1/public/$uri
            /absolute/path/to/your/project/custom/app2/public/$uri
        /;
    }

    # LIBS

    # csslib
    location ^~ /csslib/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/csslib/;
    }
    # jslib
    location ^~ /jslib/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/jslib/;
    }
    # bootswatch
    location ^~ /bootswatch/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/bootswatch/;
    }
}



Tools

Apache

In case some of your custom view's requests needs to be handled by Apache, then add this to the nginx vhost file.

# apache
location /some-slug/ {
    proxy_pass http://127.0.0.1:8008/some-slug/;
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Edit /etc/apache2/ports.conf.

# When nginx is set as proxy
NameVirtualHost 127.0.0.1:8008
Listen 127.0.0.1:8008

Create apache's virtual host file.

<VirtualHost 127.0.0.1:8008>
    ServerAdmin admin@localhost
    # apache's vhost name MUST MATCH the nginx's vhost name
    ServerName admin.example.com
    ServerAlias admin.example.com
    # some aliases etc.
    Alias /some-slug /path/to/some/dir
    <Location /some-slug>
        # some options ...
    </Location>
    # log
    ErrorLog /var/log/apache2/admin-error.log
    CustomLog /var/log/apache2/admin-access.log combined
</VirtualHost>
view on GitHub