Skip to content

13. Developing · Contents

14. Deploying to production

2. Getting started walks the same stack a production site runs, with every managed service played by a container: EMQX for the broker, MinIO for object storage, PostgreSQL in a container instead of a managed one. 12. Operating already says what changes:

Component Local Production
Broker EMQX container AWS IoT Core, or any MQTT broker with shared subscriptions
Object storage MinIO S3
Database PostgreSQL container Managed PostgreSQL
Identity Keycloak container Keycloak, or any OIDC provider

Nothing in the backend, the cabinet or the dashboard knows the difference: each of the four is reached through a URL and a credential, never through code that says "this is AWS". This chapter is one concrete way to make that swap, on AWS, with Terraform for the cloud and a Helm chart for the backend. Treat the snippets as a worked shape, not a chart to copy-paste: sizing, networking and secrets handling belong to whoever runs the site.

flowchart LR
    subgraph Local["docker compose"]
        L1["EMQX"]
        L2["MinIO"]
        L3["PostgreSQL container"]
        L4["Keycloak container"]
    end
    subgraph AWS["This chapter"]
        A1["AWS IoT Core<br/>+ Greengrass on each cabinet"]
        A2["S3"]
        A3["RDS for PostgreSQL"]
        A4["Keycloak on EKS"]
        A5["Backend on EKS<br/>(Helm chart)"]
        A6["S3 + CloudFront<br/>(dashboard)"]
    end
    L1 -.->|"same MQTT protocol,<br/>same QoS 1, same<br/>shared subscriptions"| A1
    L2 -.->|"same S3 API"| A2
    L3 -.->|"same schema,<br/>same db-init"| A3
    L4 -.->|"same realm import"| A4

Building what gets deployed

The three images are the ones 13. Developing already builds in CI: wardn-backend, wardn-edge, wardn-frontend. Nothing about them is dev-specific. Tag them by commit, push to a registry (ECR here), and every environment runs the exact bytes CI tested.

aws ecr get-login-password --region eu-west-1 | \
  docker login --username AWS --password-stdin "$ECR_REGISTRY"

docker build -t "$ECR_REGISTRY/wardn-backend:$GIT_SHA"  ./backend  && docker push "$ECR_REGISTRY/wardn-backend:$GIT_SHA"
docker build -t "$ECR_REGISTRY/wardn-frontend:$GIT_SHA" ./frontend && docker push "$ECR_REGISTRY/wardn-frontend:$GIT_SHA"

The cabinet does not take an image in the field: it takes the wardn-edge binary, published to S3 and pushed through the OTA mechanism 7. The fleet already describes. Building it for a cabinet's actual hardware, rather than the container target docker build -t wardn-edge-device ./edge produces, is a cross-compilation concern outside this chapter.

The cabinets: AWS IoT Core and Greengrass

The local broker's own comment states the intent directly: EMQX "stands in for AWS IoT Core: same MQTT protocol, same QoS 1 semantics, same shared-subscription support the backend relies on to scale out." Moving to IoT Core changes an endpoint and a certificate, not a line of the backend's MQTT handling.

A cabinet's certificate still decides which cabinet is talking, the same principle as 10. Security. An IoT policy expresses the same confinement EMQX's ACL does, with the same shape:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["iot:Connect"],
      "Resource": "arn:aws:iot:eu-west-1:*:client/${iot:Connection.Thing.ThingName}"
    },
    {
      "Effect": "Allow",
      "Action": ["iot:Publish", "iot:Subscribe", "iot:Receive"],
      "Resource": "arn:aws:iot:eu-west-1:*:topic/wardn/devices/${iot:Connection.Thing.ThingName}/*"
    }
  ]
}

One IoT policy, attached to every cabinet's certificate: the ${iot:Connection.Thing.ThingName} variable is what confines each connection to its own subtree, exactly as ${username} does in 10. Security's EMQX rule. The backend's own certificate gets a second policy scoped to wardn/#, matching wardn-backend's rule today.

Greengrass runs the binary; it does not replace wardn's own OTA safety. Registering each cabinet as a Greengrass core device buys AWS-managed provisioning, remote log shipping and a local secret store for the device certificate, on top of IoT Core connectivity. wardn-edge deploys as a Greengrass component, but the digest check, the staged install and the boot-confirms-the-release rollback 7. The fleet describes stay exactly as written: that logic lives in the binary, not in whatever supervises it, on a container, a bare board or under Greengrass alike.

The cloud side, in Terraform

Four resources, matching the table at the top of this chapter. Illustrative, trimmed of the networking (VPC, subnets, security groups) every real deployment already has its own conventions for.

# ── Managed PostgreSQL ────────────────────────────────────────────────
resource "aws_db_instance" "wardn" {
  identifier          = "wardn"
  engine              = "postgres"
  engine_version      = "16"
  instance_class      = "db.r6g.large"
  allocated_storage   = 100
  multi_az            = true
  storage_encrypted   = true
  db_subnet_group_name   = aws_db_subnet_group.wardn.name
  vpc_security_group_ids = [aws_security_group.wardn_db.id]

  # db-init still owns the schema, the roles and the grants (§8.1). This
  # provisions the instance; db-init is run once against its endpoint,
  # exactly as it runs against the local container today.
  username = "wardn_owner"
  password = data.aws_secretsmanager_secret_version.wardn_owner.secret_string
}

# ── Object storage: firmware, documents, backups ──────────────────────
resource "aws_s3_bucket" "ota"     { bucket = "wardn-ota" }
resource "aws_s3_bucket" "docs"    { bucket = "wardn-docs" }
resource "aws_s3_bucket" "backups" { bucket = "wardn-backups" }

resource "aws_iam_user" "backend" { name = "wardn-backend" }

# The scope S3_ACCESS_KEY / S3_SECRET_KEY carry: object read/write on the
# two application buckets, nothing on the account. The same confinement
# infra/minio/entrypoint.sh gives the backend's service account locally.
resource "aws_iam_user_policy" "backend" {
  user = aws_iam_user.backend.name
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
      Resource = [
        aws_s3_bucket.ota.arn, "${aws_s3_bucket.ota.arn}/*",
        aws_s3_bucket.docs.arn, "${aws_s3_bucket.docs.arn}/*",
      ]
    }]
  })
}

# ── The fleet's broker ─────────────────────────────────────────────────
resource "aws_iot_policy" "cabinet" {
  name   = "wardn-cabinet"
  policy = file("${path.module}/iot-cabinet-policy.json")
}

# ── The dashboard: a static build behind a CDN ─────────────────────────
resource "aws_s3_bucket" "dashboard" { bucket = "wardn-dashboard" }

resource "aws_cloudfront_distribution" "dashboard" {
  enabled             = true
  default_root_object = "index.html"

  origin {
    domain_name              = aws_s3_bucket.dashboard.bucket_regional_domain_name
    origin_id                = "dashboard"
    origin_access_control_id = aws_cloudfront_origin_access_control.dashboard.id
  }

  default_cache_behavior {
    target_origin_id       = "dashboard"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods          = ["GET", "HEAD"]
    forwarded_values { query_string = false; cookies { forward = "none" } }
  }

  # An Angular SPA route that is not a file: fall back to index.html and
  # let the client-side router take it from there.
  custom_error_response {
    error_code         = 404
    response_code       = 200
    response_page_path = "/index.html"
  }

  restrictions { geo_restriction { restriction_type = "none" } }
  viewer_certificate { cloudfront_default_certificate = true }
}

WARDN_API_URL in the dashboard's build still names the address a browser dials, per 12. Operating; here that is the backend's own domain, not CloudFront's.

Keycloak, and the backend, on EKS

Keycloak stays exactly what it is locally: a realm imported from this repository, running as a normal workload rather than a managed AWS service, because 12. Operating already scopes identity as "Keycloak, or any OIDC provider" rather than promising a managed one. Running it on the same EKS cluster as the backend is the simplest option, not the only one.

The Helm chart's shape

wardn/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── backend-statefulset.yaml
    ├── backend-service.yaml
    ├── backend-configmap.yaml
    ├── db-init-job.yaml
    └── ingress.yaml

The backend is a StatefulSet, not a Deployment. 12. Operating already says why: each instance needs a stable INSTANCE_ID, and "in Kubernetes this is the StatefulSet ordinal." A pod named wardn-backend-0 keeps that name across restarts; a Deployment's pods do not.

# values.yaml (excerpt)
backend:
  image: "111111111111.dkr.ecr.eu-west-1.amazonaws.com/wardn-backend"
  tag: "GIT_SHA"
  replicas: 3
  env:
    MQTT_URL: "mqtts://xxxxxxxxxxxxxx-ats.iot.eu-west-1.amazonaws.com:8883"
    KEYCLOAK_ISSUER_URL: "https://auth.wardn.example.com/realms/wardn"
    S3_ENDPOINT: "https://s3.eu-west-1.amazonaws.com"
    S3_OTA_BUCKET: "wardn-ota"
    CORS_ORIGINS: "https://dashboard.wardn.example.com"
  secretName: wardn-backend-secrets   # DATABASE_URL, S3 keys, MQTT client cert
# templates/backend-statefulset.yaml (excerpt)
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: {{ .Release.Name }}-backend
spec:
  serviceName: {{ .Release.Name }}-backend
  replicas: {{ .Values.backend.replicas }}
  selector:
    matchLabels: { app: wardn-backend }
  template:
    metadata:
      labels: { app: wardn-backend }
    spec:
      containers:
        - name: backend
          image: "{{ .Values.backend.image }}:{{ .Values.backend.tag }}"
          # The pod's own name is `<statefulset>-<ordinal>`, e.g.
          # wardn-backend-0, wardn-backend-1 — the same shape docker-compose
          # gives backend / backend-2 today.
          env:
            - name: INSTANCE_ID
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
          envFrom:
            - configMapRef: { name: {{ .Release.Name }}-backend-config }
            - secretRef: { name: {{ .Values.backend.secretName }} }
          ports: [{ containerPort: 3000 }]
          readinessProbe: { httpGet: { path: /health/ready, port: 3000 } }
          livenessProbe: { httpGet: { path: /health, port: 3000 } }

db-init runs unchanged: still the single source of truth for the schema, the roles and the grants, still idempotent, now as a Helm pre-install/pre-upgrade hook Job against the RDS endpoint instead of the local container.

# templates/db-init-job.yaml (excerpt)
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-db-init
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: db-init
          image: postgres:16-alpine
          envFrom: [{ secretRef: { name: {{ .Values.backend.secretName }} } }]
          command: ["/bin/sh", "/db/init/entrypoint.sh"]

Every other environment variable in this chart is the same one documented in 12. Operating: production changes where the value points, never what the variable means.

What stays exactly as documented

  • Certificates. The authority is the manufacturing CA, and a cabinet's key never leaves its secure element, per 10. Security. IoT Core's device certificates are provisioned through it the same way.
  • Backups. make backup already targets an S3-compatible endpoint through BACKUP_REMOTE_ENDPOINT — see 12. Operating. Point it at the wardn-backups bucket above and nothing else changes: same manifest, same encryption before it leaves, same make backup-verify.
  • Retention and erasure. Unaffected by where PostgreSQL runs; both are the application talking to its own database, per 11. Personal data.

wardn runs the same way everywhere it runs. That was the whole argument of this documentation, and this chapter is where it gets tested against a real cloud.