How to use Docker and TypeScript without compromises
Oct 27, 2018 20:41 · 856 words · 5 minute read
TypeScript is a great language to develop in, giving developers the advantages of JavaScript while removing many of the downsides. However, when using TypeScript with Node.js and Docker it may not be obvious what the ideal setup for your project up is. My team and I ran into this when working on a project recently, but we found a setup that gave us the best of both TypeScript and Docker that I thought would be good to share.
We had settled on our stack being made up of a single-page app built with React that could be served statically and a public REST API which the webapp and any other apps would consume. We decided to build the API using Node.js and TypeScript with Postgres as our database, but we also needed our tooling to be fully cross-platform since between us our team was using all three of Linux, Windows, and macOS. As such, containers were the obvious choice since they gave us the ability to manage services separately and the flexibility to develop on our platforms of choice via Docker and Docker Compose.
Originally, we were compiling the API app with tsc and adding the compiled JavaScript to our API Docker image. This setup makes sense for production and CI but was far from ideal for dev since it meant rebuilding the whole image whenever you want to see your changes. Luckily, there are a few things we were able to do to improve this.
The first improvement we made was to mount the project directory as a Docker volume at runtime rather than adding it to the image at build time, meaning that we no longer needed to rebuild the image whenever we made a change. This also meant that we needed dependencies to be installed at runtime instead of build time too, since the source code is no longer present at build time. In fact, now there was no need to build or use an image in our dev environment at all!
To take advantage of this, we set up separate Compose files for our dev and production environments, with a common base docker-compose.yml for shared config that is extended by docker-compose.override.yml for dev (which is automatically used by docker-compose up) and docker-compose.prod.yml for prod.
We also defined a node_modules named volume which was mounted to replace the node_modules directory in the mounted project directory (e.g. having mounted our directory at /usr/src/app the node_modules volume was mounted at /usr/src/app/node_modules). This speeds things up even more by persisting node_modules between starts and even environments, meaning that when yarn is run it won’t have to install anything if our dependencies haven’t changed, regardless of whether we’re switching between dev or prod!
Our config now looked like this:
# docker-compose.override.yml
version: '3.4'
services:
server:
# directly using an image instead of building our own
image: node:10.10.0-alpine
# runs yarn at runtime so mounts are active
command: sh -c "yarn --no-progress && yarn start"
# mounting our API source directory
volumes:
- ./server:/usr/src/app
postgres:
image: project-postgres:dev
build:
target: dev
# docker-compose.prod.yml
version: '3.4'
services:
server:
image: project-server:prod
build:
# builds project-server image defined by ./server/Dockerfile
# which does the original, full build
context: ./server
target: prod
environment:
NODE_ENV: 'production'
postgres:
image: project-postgres:prod
build:
target: prod
# docker-compose.yml
version: '3.4'
services:
server:
working_dir: /usr/src/app
volumes:
# use a volume to cache node_modules
- node_modules:/usr/src/app/node_modules
depends_on:
- postgres
ports:
- '8080:8080'
environment:
DB_HOST: postgres:5432
env_file: ./server/.env
postgres:
build:
context: ./database
environment:
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
ports:
- '5432'
volumes:
? db-data
? node_modules
// package.json scripts
{
"start:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up",
"start": "docker-compose up",
"build": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml build"
}
With this config we could easily start the API service in dev mode with a simple yarn start in our project root as docker-compose up automatically extends the docker-compose.yml with docker-compose.override.yml, and building the API service for production and CI was as easy as running yarn build.
Removing the image build step sped things up quite a bit, but we still needed to recompile and restart the app to view changes.
While you could use tsc --watch to make recompiling snappier, restarting would still require manual intervention.
Instead, we switched to using ts-node-dev, a package which combines the automatic restarting of node-dev with the compile-free TypeScript execution of ts-node. This was perfect for us and not only was restarting now hassle-free, but skipping compilation made restarts near instantaneous, giving us almost immediate feedback when we wanted to try our changes.
We also needed to use ts-node-dev in poll mode due to inotify being unavailable on Windows.
// server/package.json scripts
{
"build": "tsc",
"start": "ts-node-dev --poll src/index.ts",
"test": "jest",
"lint": "tslint -t verbose -p ."
}
With these changes developing was now fast and easy, letting us focus on what mattered instead of fighting with our tools. We also made some other changes to make Jest easy to use for testing with our Docker services up, but you’ll be able to see how we did that in another post. Hopefully you’ll find our experiences useful whenever you find yourself working with containerized TypeScript services.