Back to Blog
Cloud & DevOps

Containerizing IoT Backend Services with Docker: From Dev to Production

Running your MQTT bridge directly on a server is fine until the server gets a kernel update and your process silently changes behaviour. Docker gives IoT backends the reproducibility, isolation, and deployment predictability they need at every scale.

October 5, 2024
12 min read
DockerIoT BackendContainerizationMQTT

Containerizing IoT Backend Services with Docker: From Dev to Production

IoT backends are not single services. A typical production system includes an MQTT bridge, a REST API, a WebSocket server, a time-series database, a visualisation layer, and often a rules engine. Running all of these as bare processes on a single server creates a brittle, undocumented environment that is impossible to replicate and painful to scale.

Docker solves this by making each service a self-contained, reproducible unit. The same image that runs on a developer's MacBook runs identically on an AWS ECS Fargate task in production. No "works on my machine" failures. No undocumented system dependencies. No manual installation steps that differ between environments.

Why IoT Backends Benefit Especially from Containers

IoT services have characteristics that make containerisation particularly valuable:

Long-lived processes with strict resource contracts: An MQTT bridge holding 5,000 persistent connections needs a predictable memory ceiling. Docker's --memory flag enforces this; a bare process can leak into available RAM and crash the server.

Version-pinned protocol dependencies: MQTT broker versions matter. Mosquitto 2.x has a different default ACL model than Mosquitto 1.6. Pin the version in your Docker image and every environment gets exactly that behaviour.

Multi-service coordination: Your MQTT bridge needs to start after the broker but before the API. Docker Compose depends_on with health checks enforces this ordering reliably.

Environment parity: The gap between development and production is where most IoT bugs hide. If a developer runs InfluxDB 2.6 locally and production runs 2.4, query syntax differences cause failures that are hard to diagnose. Containers pin the version everywhere.

The Full Local Stack with Docker Compose

# docker-compose.yml
version: '3.9'

services: mosquitto: image: eclipse-mosquitto:2.0.18 restart: unless-stopped ports: - "1883:1883" - "8883:8883" # TLS - "9001:9001" # WebSocket volumes: - ./config/mosquitto:/mosquitto/config:ro - mosquitto-data:/mosquitto/data - mosquitto-log:/mosquitto/log healthcheck: test: ["CMD", "mosquitto_sub", "-t", "$$SYS/#", "-C", "1", "-i", "healthcheck", "-W", "3"] interval: 15s timeout: 10s retries: 3

mqtt-bridge: build: context: ./services/mqtt-bridge dockerfile: Dockerfile target: production restart: unless-stopped depends_on: mosquitto: condition: service_healthy influxdb: condition: service_healthy environment: - NODE_ENV=production - MQTT_URL=mqtt://mosquitto:1883 - INFLUX_URL=http://influxdb:8086 - INFLUX_TOKEN_FILE=/run/secrets/influx_token secrets: - influx_token ports: - "3001:3001" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"] interval: 30s timeout: 5s retries: 3

api: build: context: ./services/api dockerfile: Dockerfile target: production restart: unless-stopped depends_on: mqtt-bridge: condition: service_healthy environment: - NODE_ENV=production - DATABASE_URL=postgresql://postgres:5432/iotdb - JWT_SECRET_FILE=/run/secrets/jwt_secret secrets: - jwt_secret ports: - "3000:3000"

influxdb: image: influxdb:2.7-alpine restart: unless-stopped environment: - DOCKER_INFLUXDB_INIT_MODE=setup - DOCKER_INFLUXDB_INIT_USERNAME=admin - DOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/influx_admin_password - DOCKER_INFLUXDB_INIT_ORG=codecaracal - DOCKER_INFLUXDB_INIT_BUCKET=telemetry - DOCKER_INFLUXDB_INIT_RETENTION=90d secrets: - influx_admin_password volumes: - influxdb-data:/var/lib/influxdb2 healthcheck: test: ["CMD", "influx", "ping"] interval: 20s timeout: 5s retries: 5

grafana: image: grafana/grafana:10.2.3 restart: unless-stopped depends_on: influxdb: condition: service_healthy environment: - GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/grafana_admin_password - GF_INSTALL_PLUGINS=grafana-worldmap-panel secrets: - grafana_admin_password volumes: - grafana-data:/var/lib/grafana - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro - ./config/grafana/datasources:/etc/grafana/provisioning/datasources:ro ports: - "3100:3000"

secrets: influx_token: file: ./secrets/influx_token.txt influx_admin_password: file: ./secrets/influx_admin_password.txt jwt_secret: file: ./secrets/jwt_secret.txt grafana_admin_password: file: ./secrets/grafana_admin_password.txt

volumes: mosquitto-data: mosquitto-log: influxdb-data: grafana-data:

Production Dockerfile for the MQTT Bridge Service

The multi-stage build pattern is essential: the build stage installs dev dependencies and compiles TypeScript; the production stage copies only the compiled output and production dependencies.

# services/mqtt-bridge/Dockerfile

── Build stage ──────────────────────────────────────────────────────────────

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./ RUN npm ci --include=dev

COPY tsconfig.json ./ COPY src/ ./src/ RUN npm run build # tsc → dist/

── Production stage ─────────────────────────────────────────────────────────

FROM node:20-alpine AS production

Non-root user for security

RUN addgroup -g 1001 -S nodejs && adduser -S iotservice -u 1001 -G nodejs

WORKDIR /app

Only production dependencies

COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force

Copy compiled output from builder

COPY --from=builder --chown=iotservice:nodejs /app/dist ./dist

USER iotservice

Health check endpoint

EXPOSE 3001 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3001/health || exit 1

CMD ["node", "dist/index.js"]

Secrets Management: Docker Secrets vs Environment Variables

Never pass secrets as plain environment variables in production. They appear in docker inspect output, process listings, and container logs.

| Method | Dev | Production | Secret exposure risk | |---|---|---|---| | Plain env var | OK | Avoid | High (visible in docker inspect) | | .env file | OK | Avoid | High (often committed to git) | | Docker secrets | Good | Recommended | Low (mounted as tmpfs file) | | AWS Secrets Manager | Good | Best | Very low (fetched at runtime) |

In your Node.js service, read secrets from files:

const fs = require('fs');

function readSecret(name) { const secretPath = `/run/secrets/${name}`; if (fs.existsSync(secretPath)) { return fs.readFileSync(secretPath, 'utf8').trim(); } // Fallback to environment variable for local dev without Docker secrets return process.env[name.toUpperCase()]; }

const INFLUX_TOKEN = readSecret('influx_token'); const JWT_SECRET = readSecret('jwt_secret');

ECS Task Definition for Production

When you move from local Docker Compose to AWS ECS Fargate in production, the task definition mirrors your Compose service configuration — with secrets fetched from AWS Secrets Manager instead of local files:

{
  "containerDefinitions": [{
    "name": "mqtt-bridge",
    "image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/mqtt-bridge:v2.4.1",
    "cpu": 256,
    "memory": 512,
    "secrets": [
      {
        "name": "INFLUX_TOKEN",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:iot/influx-token"
      }
    ],
    "healthCheck": {
      "command": ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"],
      "interval": 30,
      "timeout": 5,
      "retries": 3,
      "startPeriod": 10
    },
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/mqtt-bridge",
        "awslogs-region": "us-east-1",
        "awslogs-stream-prefix": "ecs"
      }
    }
  }]
}

The discipline of containerising from day one means that when you need to scale from 1 ECS task to 10 — or migrate from ECS to EKS — no application code changes. Only infrastructure configuration changes.

---

Need your IoT backend containerised and deployed to production on AWS? [Contact Code Caracal](/contact) — we handle the full Docker and ECS architecture as part of our cloud backend engagements.

⚡
Written by CodeCaracal Engineering

We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

Let's Build Together

Got an IoT challenge?
We've shipped it.

Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

No upfront commitment99.9% uptime SLANDA on requestFixed-price options