Local Svelte development with SSL/TLS support

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.

Generate locally trusted certificates

mkcert is a command line tool that makes it ridiculously simple to generate locally trusted certificates, for development.

  1. Install mkcert, as documented in the readme. In my case, running Windows, it's just a choco install mkcert
  2. Generate and install the CA (Certificate Authority): mkcert -install
  3. Create a new certificate for the project: mkcert elborai.me localhost
  4. And that's it, just rename the key and certificate to something that makes sense

Reverse proxy

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.

Svelte dev environment ...

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 interface
  • TLS_KEY=../dev/certs/elborai.me-key.pem, the certificate key
  • TLS_CERT=../dev/certs/elborai.me.pem, the certificate

And 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,
		}),
		// ...
	],
    // ...

... locally (on the host)

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
}

... in Docker

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:

  • The project code source will be mounted at /source
  • The 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
  • The port 5000 is used by Rollup itself, while 35729 is the default port for the livereload plugin
  • HOST 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 outside
  • Under Windows I faced issues where Rollup doesn't detect file changes applied to sources (Docker inotify 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

A complete config

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,
	}
}