Embedded HandbookApps
All sections › XII. Networks and DevOps for the engineer
STM32F103, STM32F407 · level intermediate · 8 min read
Open in the interactive handbook

111. Containers and infrastructure: Compose, images, reverse proxy

A device rarely lives alone: its data goes to a server where it is received, stored and displayed. That server is also part of the product, and setting it up by hand on every machine is just as bad as building firmware without a pipeline (section 110). This section builds a small but real infrastructure: a telemetry receiver in C in a container, a reverse proxy in front of it and a Compose file that brings everything up with one command. Every property is checked by experiment: the stand is brought up, crashed, recreated, and checked for what is visible from outside.

Terms used in the documentation

Term Meaning
image, layer a container image, one layer of it
container a running image
multi-stage build building an image in several stages
volume / bind mount Docker-managed storage / a host directory mounted in
Compose file, service the stand description, one program in it
healthcheck a check that the program inside is working
restart policy when the container is restarted
reverse proxy a server that accepts requests and forwards them inward
rate limiting a cap on the request rate
internal network a network with no way out

111.1 What we are building

A device talks to the proxy; the proxy is on two networks, the receiver only on the internal one, the data in a volume

Devices send readings with an HTTP POST /api/telemetry request (section 107), the receiver appends a line to a file and, on request, says how many lines it has accepted. The receiver is a small C program without libraries: this shows that the container does not care what language the program is written in.

Only the nginx proxy is open to the outside. It accepts requests, cuts off what is not needed and passes the rest to the receiver on the internal network. Three properties this is all about:


111.2 The image: two stages

The image is built from a Dockerfile. The compiler is only needed to build the program; in the running image it has nothing to do:

FROM debian:12-slim AS build
COPY receiver.c /src/receiver.c
RUN gcc -std=gnu11 -O2 -Wall -Wextra -static -o /receiver /src/receiver.c \
 && mkdir /data

FROM scratch
COPY --from=build /receiver /receiver
ENTRYPOINT ["/receiver"]

The first stage is Debian with gcc, the second is FROM scratch, an empty image into which one statically linked program is copied. The result of the experiment: the receiver image is 351346 bytes, the stage with the compiler is 98 MB, 279 times larger. A smaller image means faster deployment over a slow link and fewer programs in which a vulnerability might turn up: scratch does not even have a shell.

A two-stage build: the compiler stays in the first stage, one program goes into the image

The version of the base image is pinned the same way as the compiler version in the pipeline: nginx:1.31.3-alpine, not nginx:latest, — otherwise tomorrow's build will not come out the same as today's. But even a tag with a version (nginx:1.31.3-alpine, debian:12-slim) is rebuilt with security updates: the content changes, the name does not. Only the image hash @sha256:… is immutable.


111.3 Not as root

By default the process in a container runs as root. The receiver does not need that:

COPY --from=build --chown=65534:65534 /data /data
USER 65534:65534

The experiment confirmed it: the container runs as user 65534. The /data directory is created in the image with this owner right away — a new volume gets the same permissions, and the program can write to its own file, but nowhere else.


111.4 Compose: the whole stand in one file

The compose.yaml file describes services, volumes and networks:

services:
  receiver:
    build: ./receiver
    restart: on-failure
    volumes:
      - telemetry:/data
    networks:
      - back
  proxy:
    image: nginx:1.31.3-alpine
    restart: unless-stopped
    ports:
      - "127.0.0.1:18111:80"
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - front
      - back
volumes:
  telemetry:
networks:
  front:
  back:
    internal: true

The proxy is connected to both networks, the receiver only to back. A named volume is declared at the top level of the file: without the volumes section Compose refuses to start ("refers to undefined volume"). The healthcheck and start-up order are in 111.5.

The commands you use every day:

docker compose up -d --build     собрать и поднять в фоне
docker compose ps                что запущено и в каком состоянии
docker compose logs -f receiver  журнал сервиса
docker compose down              остановить и удалить контейнеры
docker compose down -v           то же вместе с томами — и данными

The proxy port is published only on 127.0.0.1: on a server it is exposed to the outside by the firewall or by an external proxy with encryption, not by Docker itself. A port published on all addresses ("18111:80") is forwarded by Docker with its own iptables rules before the packet even reaches the ufw rules (and firewalld zones): the port is visible from the network even if the firewall "forbids" it. Restrictions for published ports go into the DOCKER-USER chain — Docker checks it before its own rules.


111.5 Healthcheck and start-up order

"The container is running" does not mean "the program is ready to answer". A healthcheck asks the program itself. A scratch image has neither curl nor a shell — so the receiver can check itself:

    if (argc > 1 && strcmp(argv[1], "--check") == 0)
        return self_check();

self_check() makes a GET /api/count request to itself and returns 0 on a 200 response. In Compose:

  receiver:
    healthcheck:
      test: ["CMD", "/receiver", "--check"]
      interval: 2s
  proxy:
    depends_on:
      receiver:
        condition: service_healthy

The proxy starts only after the receiver has become healthy: the first requests from devices will not get a "service unavailable" error.


111.6 Crash and restart

The restart: on-failure policy brings the container back up if the process exited with an error. Experiment: a service request from inside the network made the receiver crash. Docker restarted it by itself — RestartCount became 1 — the healthcheck was green again, and the count of accepted lines stayed at 3: they are in a file on the volume, not in the process memory.

unless-stopped on the proxy brings it up even after a server reboot. A restart is insurance, not a cure: frequent restarts show up in RestartCount and should turn into an alert (section 112).


111.7 Volumes: data separate from the container

A container is disposable: it is deleted on every image update. The data lives in the telemetry volume mounted at /data. Experiment:

So down -v on a production server is a command after which you need a backup. A volume with important data is backed up on a schedule — for example, with a separate container running tar, mounted on the same volume.


111.8 Networks: only the proxy faces outward

The receiver publishes no ports and is connected only to the back network with internal: true — it has no way out. The proxy is connected to both networks. Experiment: a container on the external front network could not reach the receiver, while a container on the back network could. Inside a network services find each other by name: the proxy talks to receiver:8080, nobody writes addresses.

If the receiver is compromised, it has nowhere to send data from the internal network; if the proxy is compromised, it sees only the receiver, not everything else on the server.


111.9 The reverse proxy

limit_req_zone $binary_remote_addr zone=tele:1m rate=5r/s;

location = /api/crash {
    return 403;
}

location /api/ {
    limit_req zone=tele burst=5 nodelay;
    limit_req_status 429;
    client_max_body_size 1k;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://receiver:8080;
}

What the proxy did in the experiment:

Request Response
three lines to /api/telemetry 200, the receiver counted 3
the service /api/crash 403 — not let out
anything outside /api/ 404
a body larger than 1 KB 413
20 requests in a row 6–7 passed, the rest got 429

The limit is 5 requests per second with a burst of 5: a broken device that sends non-stop will not bring the receiver down. The receiver sees the connection from the proxy, and gets the real client address in the X-Real-IP header: through the proxy /api/whoami returned the client address, directly — a dash. On a production server the same proxy holds the HTTPS certificate — the receiver knows nothing about encryption.


111.10 Pitfalls

The container "works", but the service does not answer. There is no healthcheck, dependent services start too early. healthcheck and condition: service_healthy.

Data disappeared after an update. Data was written inside the container, not to a volume. Everything that must outlive the container goes into volumes.

permission denied when writing to a volume. The program does not run as root, but the directory belongs to root. Create the directory in the image with the right owner.

The build gave different images yesterday and today. The base image is latest. Pin versions.

The receiver is reachable from the internet bypassing the proxy. Its port is published. Publish only the proxy, keep the receiver on the internal network.

Every entry in the receiver log has the same address. That is the proxy's address. Take X-Real-IP, and have the proxy set it.

down -v on a server. It deletes volumes with data. Back up first.


111.11 Summary

Next: section 112 "Observability" — logs, metrics and alerts for this stand.

Open in the interactive handbook