Deploy microservices with Docker & autoscaling

Ship your services: per-service Docker images, environment-based configuration, and horizontal scaling for any load.

This chapter covers the various aspects of deploying @imqueue-based services.

There are many ways to deploy, depending on your needs. The most immediate is development deployment; another is shipping production packages. The deployment scheme also varies by environment — you might want to use every physical core of a single server, or spread many small containerised images across a network.

@imqueue-based services are ready to meet any of these needs, but it's up to your developers and DevOps to decide how. The @imqueue/cli default template also provides some ready-to-use deployment functionality out of the box.

For services to scale in any direction, they must either be stateless or provide a mechanism to synchronise state across processes and network instances. Keep this in mind throughout implementation.

What does that mean in practice?

Imagine a service that keeps some state in memory — say, the list of authenticated users:

import { IMQService, expose } from '@imqueue/rpc';

class MyService extends IMQService {
    private usersList: any[] = [];

    @expose()
    public addUser(data: any) {
        this.usersList.push(data);
    }
}

Each time a remote client calls addUser(), the service mutates its in-memory state. With a single instance running, this works fine. But launch several copies and you're in trouble: after a few addUser() calls, each copy holds a different internal state — and the divergence is unpredictable.

That's undesirable, so you must either implement a mechanism to share state between copies, or store and mutate the state in an external tool such as a database. Rolling your own state-sharing is usually non-trivial and prone to side-effects, so unless you're confident in how it works, we recommend designing your services to be stateless. Stateless services behave predictably across every deployment scenario.

For example, this tutorial suggests implementing the Car service with an in-memory car database, which is stateful by design. But that database is largely static data: we refresh it roughly once every 24 hours, all running copies do so at about the same time, and we otherwise only read from it. There are minor side-effects, but they're insignificant for this system. It's neither good nor bad — you just need to understand what you're doing and what the consequences are.

Scaling options

Services are designed to run in multi-process environments. Since JavaScript on Node.js is single-threaded by nature, one process uses the power of only one core. On a multi-core machine you'll usually want to use all available cores, which you can do with a couple of configuration options. These are service options (they live on IMQServiceOptions), and can be set in the service's config.ts:

export const serviceOptions: Partial<IMQServiceOptions> = {
    multiProcess: true,  // default: false — turned off
    childrenPerCore: 2,  // default: 1
};

Set multiProcess: true to enable multi-process mode (or manage the value through environment variables). By default this forks one worker per available core. In real-world runs you may find that using all available capacity requires more than one process per core: increasing childrenPerCore adds more context switching per core but can still yield an overall performance gain. Treat it as a tuning knob — experiment to find the value that works best for your workload.

If your deployment is based on small single-core containers, you probably don't need to touch the multi-process options at all. Either way, the right settings come from testing and experimentation.

Building containers

The @imqueue/cli default template gives each service a Dockerfile and a set of Docker npm scripts, so you can build and run an image for any service locally:

npm run docker:build
npm run docker:run
npm run docker:stop
npm run docker:ssh

Local builds require, of course, a Docker engine installed on your machine.

Continuous integration runs on GitHub Actions. Every service created by @imqueue/cli ships a .github/workflows/build.yml that, on each push and pull request, installs dependencies and runs the test suite across the current LTS and latest Node.js versions — verifying the build stays green. (Which CI provider is wired in depends on how you configured the tool; GitHub Actions is the default, with CircleCI and Travis also available.)

Building and publishing images to a container registry is left to your own deployment pipeline — run npm run docker:build there and push the resulting image. Pre-built Docker images can then be pulled and deployed across many cloud environments — AWS, Azure, Google Cloud Platform and others. From there it's a matter of configuring your cloud environment: enabling auto-scaling and anything else you need.

One important note about running @imqueue clients in Docker containers: unless you name your clients explicitly, each client generates a unique name based on the operating system's UUID. Since Docker images share the same OS UUID out of the box, you should set a unique value on the first image build — usually in /etc/machine-id or /var/lib/dbus/machine-id. Consult the documentation for your container's base OS to find the correct location.

Environment variables

Environment variables are a powerful way to separate configuration across environments without maintaining multiple config codebases. On cloud platforms such as AWS you might use Parameter Store to supply configuration, while for local development you can use .env files.

This requires some setup in the service's config.ts. Configure each option to read from an environment variable first (which you define yourself) and fall back to a default value. We covered this in chapter 2.

We strongly recommend following the same approach for any configuration in your real-world services, and documenting the expected environment variables in your README files — so that anyone deploying to a new environment can tune their setup easily.

Running it locally

We've covered the many options available when deploying @imqueue services. As you can see, it's a flexible solution, able to satisfy any load and suitable for horizontal scaling and cloud deployments.

For this tutorial we'll focus on the default development environment, so you can run the example services from our codebase and experiment with them.

First, clone all the repositories locally. Let's assume a dedicated directory, for example ~/imqueue-sandbox:

mkdir ~/imqueue-sandbox
cd ~/imqueue-sandbox
git clone git@github.com:imqueue-sandbox/api.git
git clone git@github.com:imqueue-sandbox/auth.git
git clone git@github.com:imqueue-sandbox/car.git
git clone git@github.com:imqueue-sandbox/time-table.git
git clone git@github.com:imqueue-sandbox/user.git
git clone git@github.com:imqueue-sandbox/web-app.git

Next, make sure Redis, MongoDB and PostgreSQL are running on your development machine. Out of the box the services look for them at the standard local addresses — localhost:6379, localhost:27017 and localhost:5432.

You'll also need a PostgreSQL database named tutmq, owned by a user tutmq with the password tutmq — or point the time-table service at a different database through its DB_CONN_STR environment variable. The schema itself is created on start-up, so there's nothing to migrate by hand. If you want to watch the statements it issues, start it with SQL_LOG=1; SQL_PRETTIFY=1 and SQL_COLORIZE=1 make them readable.

The auth service signs its JWTs with a secret you supply and refuses to start without one. .env files are git-ignored, so a fresh clone has none — generate your own secret before starting the fleet:

cd ~/imqueue-sandbox/auth
echo "JWT_KEY=$(openssl rand -hex 32)" > .env

Then install dependencies and run each service in its own terminal window (or use a multiplexer such as screen or tmux if you prefer):

cd ~/imqueue-sandbox/[service_dir]
npm i
npm run dev

where [service_dir] is one of user, auth, car, time-table or api. The services talk to each other over the message queue and ship pre-generated static clients, so you can start them in any order — a service simply queues its calls until the peer it needs becomes available.

Once the API service is running, the GraphiQL web interface is available at http://localhost:8888/.

Finally, install and start the React-based web interface:

cd ~/imqueue-sandbox/web-app
npm i
npm start

npm start first runs the Relay compiler over the TypeScript sources — regenerating the typed query artifacts in the __generated__ directories — and then starts the Vite dev server. You can now use the application at http://localhost:3000/.

Bonus: the same fleet, a different API

The tutorial proper ends here. But if you'd like proof that nothing about the fleet ties you to GraphQL, carry on to the bonus chapters: we rebuild the API gateway as a REST/OpenAPI service and add a second front-end that speaks REST natively — both stacks running side by side, without changing a single line in the back-end services.

Next up: Bonus: REST API.

Last updated

Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.