diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..c2658d7d1b31848c3b71960543cb0368e56cd4c7
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/.env.template b/.env.template
index dea774d3092bfa2afbef908eaad4577b241c4f49..684fbdac0bf2feaaa5085aaa15fee53c103955b7 100644
--- a/.env.template
+++ b/.env.template
@@ -1,7 +1,16 @@
-# host port
+# docker-compose components. Append ':devserver.yml' to also run the dev server (default is prod only)
+COMPOSE_FILE=docker-compose.yml
+# prod server host port
 PORT=8080
-# container restart policy
+# prod container restart policy
 RESTART=unless-stopped
+# dev server host port
+DEV_PORT=9000
+# dev server container restart policy
+DEV_RESTART=unless-stopped
 # HTTP folder, will be served at http://localhost:$PORT/data
 # use absolute path or relative path starting with ./
 HTTP_FOLDER=./www
+# hosts allowed to access ressources from $HTTP_FOLDER
+# * to allow all, http://localhost:$DEV_PORT to allow only devserver
+CORS_ALLOWED_HOSTS=http://localhost:$DEV_PORT
diff --git a/Caddyfile b/Caddyfile
index f3c6a8f2e20d422abbfd9903b36539e1c4b350a5..17dfffa7a40bcf75da517caf760bc90585cb9718 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -1,4 +1,5 @@
 :80 {
-  root * /srv
-  file_server browse
+	root * /srv
+	file_server browse
+	header Access-Control-Allow-Origin "{env.CORS_ALLOWED_HOSTS}"
 }
diff --git a/Dockerfile b/Dockerfile
index d89317f1d520f899d10483b1d0aac8c46ba2bad2..78ed874c334be9770de9a02cdee600cef5693233 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@ RUN apk add npm git
 
 COPY . /opt
 WORKDIR /opt
-RUN npm install
+RUN npm ci
 RUN npm run build
 
 FROM caddy:latest as httpd
diff --git a/Dockerfile.devserver b/Dockerfile.devserver
new file mode 100644
index 0000000000000000000000000000000000000000..f50507ac44ce67c081bb374c1fdc66a5d570eb65
--- /dev/null
+++ b/Dockerfile.devserver
@@ -0,0 +1,6 @@
+FROM node:16-alpine
+RUN apk add git
+EXPOSE 9000
+WORKDIR /opt
+USER node
+CMD npm run serve
diff --git a/README.md b/README.md
index cedf460ce67265e7f12a7ce45483dbdf2802644b..155805fdda8c92e25798633f9a57127e847efe09 100644
--- a/README.md
+++ b/README.md
@@ -7,3 +7,13 @@ Run `docker-compose up -d`, which will serve a mirador instance at `http://local
 The `$HTTP_FOLDER` (`./www` by default) directory will be accessible via HTTP at `http://localhost:$PORT/data` and can be used to store manifests and theirs ressources and see them in Mirador.  
 
 If sources files are modified, run `docker-compose up -d --build` to update Mirador  
+
+
+#### Use the development server
+
+Follow the previous instructions if you want to access ressources from `$HTTP_FOLDER` via HTTP in the devserver.  
+
+Edit `.env` (copy it from `.env.template` if needed), set `COMPOSE_FILE=docker-compose.yml:devserver.yml` and adapt `DEV_*` variables to your needs.  
+
+Run `docker-compose up -d --build devserver`, which will serve a mirador instance at `http://localhost:$DEV_PORT` (default port is 9000) with live rebuild/reload enabled on each `src/` and `public/` files modifications.  
+
diff --git a/devserver.yml b/devserver.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8d6b8bfc03720fcc48a9ebdb23b79a41b9043050
--- /dev/null
+++ b/devserver.yml
@@ -0,0 +1,13 @@
+version: "3.9"
+services:
+  devserver:
+    build:
+      context: .
+      dockerfile: Dockerfile.devserver
+    restart: $DEV_RESTART
+    ports:
+      - $DEV_PORT:9000
+    volumes:
+      - ./:/opt
+    environment:
+      - WEBPACK_MODE=development
diff --git a/docker-compose.yml b/docker-compose.yml
index 5c7b261988e82552f59d3f620b308ae8ffc49fd9..31ae053949e0adfac62183b4bf71ca9facc51ad8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,6 +5,9 @@ services:
     restart: $RESTART
     ports:
       - $PORT:80
+    environment:
+      - CORS_ALLOWED_HOSTS
+      - WEBPACK_MODE=production
     volumes:
       - $HTTP_FOLDER:/srv/data
       - ./Caddyfile:/etc/caddy/Caddyfile
diff --git a/package.json b/package.json
index c247ee3f4313baa2464e4e3140ee5694f4a732d9..a3cc34e783ce0910fd9d83a0fdffae19746ecc80 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
   "private": true,
   "scripts": {
     "build": "webpack --config webpack.config.js",
-    "serve": "webpack serve --config webpack.config.js"
+    "serve": "npm install && webpack serve --config webpack.config.js"
   },
   "author": "",
   "license": "ISC",
@@ -18,6 +18,7 @@
   },
   "devDependencies": {
     "webpack": "^4.43.0",
-    "webpack-cli": "^4.3.12"
+    "webpack-cli": "^4.3.12",
+    "webpack-dev-server": "^4.11.1"
   }
 }
diff --git a/webpack.config.js b/webpack.config.js
index d487af5301c2a2d03037db99295c2bcbee3bb9a4..4fc1dec32c23fef3ecba084c7ffe0cbcc13e5b02 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,10 +1,27 @@
 const path = require('path');
+const webpack = require('webpack');
 
 module.exports = {
-  entry: './src/index.js',
-  output: {
-    filename: 'main.js',
-    path: path.resolve(__dirname, 'public/dist'),
-    publicPath: './dist/',
-  }
+    mode: process.env.WEBPACK_MODE,
+    entry: './src/index.js',
+    output: {
+      filename: 'main.js',
+      path: path.resolve(__dirname, 'public/dist'),
+      publicPath: '/dist/',
+    },
+    devServer: {
+      hot: true,
+      watchFiles: ['src/**/*'],
+      static: {
+        directory: path.join(__dirname, 'public'),
+        watch: true
+      },
+      port: 9000
+    },
+    plugins: [
+      new webpack.IgnorePlugin({
+        /* cf https://gitlab.tetras-libre.fr/iiif/mirador-video-annotation/-/blob/annotation-on-video/webpack.config.js#L42 */
+        resourceRegExp: /@blueprintjs\/(core|icons)/, // ignore optional UI framework dependencies
+      })
+    ]
 };