← ALL POSTS
3 AUGUST 2026

The Compose File Was Lying To Us: Making The Gateway Safe To Deploy

Moving the SMS gateway onto its permanent home meant reading the docker-compose file properly for the first time in a while. It was not safe to run as it stood. Five things, and a couple of them were the sort you only find by reading rather than by testing.

The port binding

The compose file had 9252:3001. No host, so that binds to 0.0.0.0.

On this box UFW allows everything on the Tailscale interface, because that is a trusted network. Which meant the API was reachable, unauthenticated, from anywhere on the tailnet. Straight past Cloudflare, straight past Access, straight past everything we put in front of it.

Nothing was going to reach it from the public internet. That is not the point. The point is the protection we thought we had was being bypassed by a default in a port mapping.

Now 127.0.0.1:9252:3001. The Cloudflare tunnel reaches it over localhost, which is the only thing that needs to.

If you run containers on a box with a trusted network interface, go and read your port mappings. HOST:CONTAINER with no host is not "local". It is everything.

The environment block that overrode the real credentials

This one is genuinely nasty.

The api service declared MONGO_URI in its environment: block, with a fallback: ${MONGO_ROOT_PASS:-adminPassword}. It also had an env_file: carrying the real generated credentials.

Compose gives environment: precedence over env_file:. So with no root .env present, that line silently overrode the good credentials and would have initialised Mongo as adminUser / adminPassword.

Not an error. Not a warning. A working database with a guessable password, and the env file you thought was in charge sitting there being ignored.

Removed the duplication, and changed the remaining interpolations from :-default to :?, which makes compose fail loudly with a message instead of quietly picking something. A default value in a credential is a worse outcome than a crash.

mongo:latest

mongo:latest and redis:alpine. Pinned to mongo:8 and redis:8-alpine.

latest on a database is a bomb on a timer. A routine docker compose pull crosses a major version, the container comes up, refuses to start on the old data files, and now you are doing an unplanned migration at whatever time of day you happened to run a pull.

Named volumes on the wrong disk

The data was in named volumes, which live under Docker's root on the boot disk. There is a second NVMe in this machine specifically for project data.

Swapped to bind mounts under /data/projects-data, with both containers running as 1000:1000 to match ownership. Database data belongs on the data disk, and a full root disk takes down everything on the box rather than just one service.

The secret in the file

There was a hardcoded inbound secret in the compose file. Removed, rotated, and now read from the API's env file.

And while we were in there

Public sign-up is closed. ALLOW_PUBLIC_REGISTRATION is false, and the register endpoint returns 403.

This is a multi-tenant gateway wired into client CRMs and sending from real SIMs. There is no version of that where a stranger should be able to create themselves an account. Tenants get created by us, deliberately, and attached to a device by an admin.

The theme

Not one of these was found by the service failing. The service was running fine. Every single one was found by sitting down and reading the config with the question "what does this actually do if the file I expect is missing".

That is a different activity to testing, and it is worth scheduling.

Want this working on your store? Aiva does the sales, marketing and service work - in your voice, around the clock.
Get Aiva