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.
$ [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
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.
The application's configuration is stored inside the config.json
file.
mysql
key accepts any option from the node-mysql's connection options.pg
key will accept any option from the node-postgres connection options.sqlite
key will contain only a database
key with the absolute path to the database set as a value.{
"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"
}
}
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:
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
}
control - one of these
{"text": true} // input type="text"
{"textarea": true} // textarea
{"textarea": true, "editor": "some-class"} // html editor (see the docs)
{"number": true} // input type="number"
{"date": true} // datepicker
{"time": true} // timepicker
{"datetime": true} // datetimepicker
{"year": true} // yearpicker
{"file": true} // input type="file" (uploads to file system)
{"file": true, "binary": true} // input type="file" (uploads to blob|bytea fields)
{"radio": true, "options": ["True","False"]} // input type="radio"
{"select": true} // select (used for one-to-many relationships)
{"select": true, "multiple": true} // select multiple (used for many-to-many relationships)
{"select": true, "options": ["value&text",{"value":"text"}]} // select with static options
type - column's data type (typically you won't change this)
All auto increment columns should be hidden!
Foreign keys for inline tables should be hidden!
null
inside the database, can't be hidden here as this will result in a database error when trying to insert or update the recordAll 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"
}
app.js
fileSee the custom views documentation and the examples
settings.json
file, find the table you are looking foroneToMany
keyselect
"control": {
"select": true
},
"oneToMany": {
"table": "user",
"pk": "id",
"columns": [
"firstname",
"lastname"
]
}
The
oneToMany
key additionally can contain aschema
key, specifying PostgreSQL schema name for the relation table
settings.json
file, find the table you are looking for{
"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 theref
keys additionally can contain aschema
key, specifying PostgreSQL schema name for the relation table
The
link
table'sparentPk
andchildPk
key can be array as well.
Theref
table'spk
key can be array as well.
See compound primary key documentation.
settings.json
file, find the table you are looking foreditview
key add a manyToOne
key"manyToOne": {
"repair": "car_id",
"driver": "car_id"
}
key
is the name of the table that is referencing this one, and the value
is its foreign keyvalue
can be array as well, see compound primary key documentation)settings.json
file, find the table you are looking foreditview
key add a oneToOne
key"oneToOne": {
"address": "user_id",
"phone": "user_id"
}
key
is the name of the table that is referencing this one, and the value
is its foreign keyvalue
can be array as well, see compound primary key documentation)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"
}
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"
]
}
}
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"
]
}
}
}
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"
]
}
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"
]
}
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
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"
}
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"
]
}
}
}
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
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
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"
}
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"
]
}
}
}
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
You should either download CKEditor and TinyMCE from their official site ckedtor.com and tinymce.com or use them directly from cdnjs.com
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"
}
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"
]
}
}
}
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
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.
"view1": {
"app": {
"path": "/absolute/path/to/custom/app.js",
"slug": "view1",
"verbose": "My Custom View",
"mainview": {
"show": true
}
}
}
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.
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
Currently the supported event hooks are:
"some-key": {
"events": "/absolute/path/to/custom/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
insert|update|remove
data - data submitted via POST request or select'ed from the database
oneToOne | manyToOne - inline tables data
"table's name": {
"records": [
"columns": {"column's name": "column's value", ...},
"insert|update|remove": "true" // only for inline records
]
}
user
created_at
and update_at
columns in user
table should be set to show: false
for editview
in settings.json
created_at
and updated_at
columns, before the record is savedvar 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();
}
car
and will contain manyToOne
inline tablesid
) should be set to show: false
for editview
in settings.json
(the same applies for any inline table that needs its primary key to be generated)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();
}
purchase
and will contain manyToOne
inline tablesdeleted
and deleted_at
columns should be set to show: false
for editview
in settings.json
deleted
and deleted_at
columns, before the record is savedvar 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();
}
insert|update|remove
data - data submitted via POST request or select'ed from the database
oneToOne | manyToOne - inline tables data
"table's name": {
"records": [
"columns": {"column's name": "column's value", ...},
"insert|update|remove": "true" // only for inline records
]
}
item
image
's column control type should be set to file:true
in settings.json
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();
}
true|false
whether to use logical or or notexports.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();
}
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.
"app": {
...
"root": "/some-path"
}
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');
});
});
Use the Themes
button to switch between the themes.
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/
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.
Run an admin instance continuously in the background using forever
$ forever start path/to/express-admin/app path/to/project/config/
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/;
}
}
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>