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 builderWORKDIR /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 productionNon-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S iotservice -u 1001 -G nodejsWORKDIR /app
Only production dependencies
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --forceCopy compiled output from builder
COPY --from=builder --chown=iotservice:nodejs /app/dist ./distUSER iotservice
Health check endpoint
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3001/health || exit 1CMD ["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.