When working on web projects it is often useful and recommended to enable SSL for your development environment. For example if your project works with cookies, it is likely that the server sets the Secure
attribute, ensuring that they only sent to the server over HTTPS. But even without cookies it's a good idea to try to minimize differences between your development and production environments. Fortunately, using Docker that can be done done easily in just a few steps.
mkcert is a command line tool that makes it ridiculously simple to generate locally trusted certificates, for development.
mkcert
, as documented in the readme. In my case, running Windows, it's just a choco install mkcert
mkcert -install
mkcert elborai.me localhost
The frontend or backend projects are unlikely to handle the SSL protocol themselves, instead the common approach is to run them behind a reverse proxy. That way we can also share the same port for both, which simplifies the whole process as we won't face CORS issues.
Caddy is the simplest web server and reverse proxy to setup that I've ever faced. The configuration is done with a Caddyfile
, that would look something like this:
# specify the domain you want to use for your local development
localhost {
# use the certificate made with mkcert
tls /root/certs/elborai.me.pem /root/certs/elborai.me-key.pem
# forward requests from which the path starts with /v1 to whatever is listening on api:5001
reverse_proxy /v1/* http://api:5001
# forward all other requests to webui:5000
reverse_proxy http://webui:5000
}
By default Caddy already handle forwarding between HTTP to HTTPS, so we have something else to do here.
And to run it, the following docker-compose.yml
does the trick:
version: "3.8"
services:
webui:
# ... assuming webui is already configured
depends_on:
- caddy
- api
api:
# ... assuming api is already configured
depends_on:
- caddy
caddy:
image: caddy:alpine
ports:
- "80:80"
- "443:443"
volumes:
# ./dev/Caddyfile is where the config file is located
- ./dev/Caddyfile:/etc/caddy/Caddyfile
# ./dev/certs is where the mkcert certificate is located
- ./dev/certs:/root/certs
# persist Caddy's data in a volume
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Assuming both api
and webui
have been setup to work in docker-compose, a docker-compose up webui
should already setup the reverse proxy, the API, and the web UI.
We can either run the dev server in Docker, or run it locally on the host machine and direct Caddy to the host's IP address.
The dev server requires 3 environment variables:
HOST=0.0.0.0
, Caddy will need to reach Rollup's web server so we should be sure that it binds to the correct network interfaceTLS_KEY=../dev/certs/elborai.me-key.pem
, the certificate keyTLS_CERT=../dev/certs/elborai.me.pem
, the certificateAnd the rollup.config.js
can be updated to enable SSL for the livereload server:
// ./webui/rollup.config.js
import svelte from 'rollup-plugin-svelte'
// ...
import fs from "fs" // 👈 import the 'fs' module
const production = !process.env.ROLLUP_WATCH
// get the certificate and key location from env vars 👇
// TLS certificates
const tlsCert = process.env["TLS_CERT"] ? process.env.TLS_CERT : ""
const tlsKey = process.env["TLS_KEY"] ? process.env.TLS_KEY : ""
// ...
plugins: [ // 👈 identify the 'plugins' block
//...
// find out the 'livereload' 👇 plugin
!production && livereload({
watch: "public",
https: (tlsCert && tlsKey) ? { // 👈 add the 'https' attribute, an object defining 'cert' and 'key'
key: fs.readFileSync(tlsKey),
cert: fs.readFileSync(tlsCert),
} : null,
}),
// ...
],
// ...
The simplest way to run the dev server on the host with the correct config is to use an .env
file:
# .env file
HOST=0.0.0.0
TLS_KEY=../dev/certs/elborai.me-key.pem
TLS_CERT=../dev/certs/elborai.me.pem
Then use the npm module dotenv at the top of your Rollup config file:
import dotenv from "dotenv"
dotenv.config() // inject the content of the .env file into 'process.env'
When running the dev server on the host, be sure to update the Caddyfile
to target the host's IP (in my case, 192.168.0.81
):
localhost {
# ...
# forward all other requests to host, port 5000
reverse_proxy http://192.168.0.81:5000
}
A basic Dockerfile
to run the dev env looks like this:
FROM node:14-alpine
VOLUME /source
VOLUME /root/certs
# Rollup server
EXPOSE 5000
# Livereload
EXPOSE 35729
ENV HOST 0.0.0.0
ENV PORT 5000
COPY . /source
WORKDIR /source
RUN npm install
CMD ["npm", "run", "dev"]
Here we can note:
/source
livereload
part of Rollup needs access to the certificate we generated with mkcert
to be able to work with SSL, so we also mount a volume at /root/certs
5000
is used by Rollup itself, while 35729
is the default port for the livereload pluginHOST
and PORT
are the two environment variables used to control the Rollup web server. When something run within Docker it should always use the interface 0.0.0.0
to be available from the outsideinotify
support under Windows seems to break quite often), but that works well under Linux (tested via WSL2) and macOS (at least for me).To use it in docker-compose.yml
, that would look like this:
services:
webui:
build: ./webui # directory that contains the Dockerfile
environment:
- HOST=0.0.0.0
# path within the docker container
- TLS_KEY=/root/certs/elborai.me-key.pem
- TLS_CERT=/root/certs/elborai.me.pem
# here you can overwrite the command to run
command: npm run dev
ports:
- "5000:5000" # Rollup web server
- "35729:35729" # Livereload
volumes:
# ./webui is the path to the source directory
- ./webui:/source
# ./dev/certs is the directory that contains the certificate and key
- ./dev/certs:/root/certs
depends_on:
- caddy
Generate the certificate in ./dev/certs
using mkcert
.
Then, the docker-compose.yml
:
version: "3.8"
services:
webui:
build: ./webui
environment:
- HOST=0.0.0.0
- TLS_KEY=/root/certs/elborai.me-key.pem
- TLS_CERT=/root/certs/elborai.me.pem
command: npm run dev
ports:
- "5000:5000"
- "35729:35729"
volumes:
- ./webui:/source
- ./dev/certs:/root/certs
depends_on:
- caddy
- api
api:
build: ./services
ports:
- "5001:5001"
environment:
- HTTP_ADDR=0.0.0.0:5001
- POSTGRES_URL=postgres://postgres:password@db:5432/default?sslmode=disable
restart: unless-stopped
depends_on:
- db
- caddy
db:
image: postgres:12-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_DB=default
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- db_data:/var/lib/postgresql/data
caddy:
image: caddy:alpine
ports:
- "80:80"
- "443:443"
environment:
- HOST_IP=192.168.0.81
- APP_DOMAIN=localhost
- CERTS_DOMAIN=elborai.me
volumes:
- ./dev/Caddyfile:/etc/caddy/Caddyfile
- ./dev/certs:/root/certs
- caddy_data:/data
- caddy_config:/config
volumes:
db_data:
caddy_data:
caddy_config:
The reverse proxy and SSL endpoint configuration, ./dev/Caddyfile
:
{$APP_DOMAIN} {
log {
output stdout
format console
}
tls /root/certs/{$CERTS_DOMAIN}.pem /root/certs/{$CERTS_DOMAIN}-key.pem
reverse_proxy /v1/* http://api:5001
# if webui running directly on host
reverse_proxy http://{$HOST_IP}:5000
# if webui running within docker-compose
# reverse_proxy http://webui:5000
}
The Svelte Dockerfile
, at ./webui/Dockerfile
:
FROM node:14-alpine
VOLUME /source
VOLUME /root/certs
# Rollup server
EXPOSE 5000
# Livereload
EXPOSE 35729
ENV HOST 0.0.0.0
ENV PORT 5000
COPY . /source
WORKDIR /source
RUN npm install
RUN npm run build
CMD ["npm", "start"]
The Rollup config file, at ./webui/rollup.config.js
:
import svelte from 'rollup-plugin-svelte'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import livereload from 'rollup-plugin-livereload'
import { terser } from 'rollup-plugin-terser'
import sveltePreprocess from 'svelte-preprocess'
import typescript from '@rollup/plugin-typescript'
import fs from "fs"
const production = !process.env.ROLLUP_WATCH
// TLS certificates
const tlsCert = process.env["TLS_CERT"] ? process.env.TLS_CERT : ""
const tlsKey = process.env["TLS_KEY"] ? process.env.TLS_KEY : ""
function serve() {
let server
function toExit() {
if (server) server.kill(0)
}
return {
writeBundle() {
if (server) return
server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
})
process.on('SIGTERM', toExit)
process.on('exit', toExit)
}
}
}
export default {
input: 'src/main.ts',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js',
},
plugins: [
svelte({
hydratable: true,
// enable run-time checks when not in production
dev: !production,
// we'll extract any component CSS out into
// a separate file - better for performance
css: css => {
css.write('bundle.css')
},
preprocess: sveltePreprocess(),
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
typescript({
sourceMap: !production,
inlineSources: !production,
}),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload({
watch: "public",
https: (tlsCert && tlsKey) ? {
key: fs.readFileSync(tlsKey),
cert: fs.readFileSync(tlsCert),
} : null,
}),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false,
}
}