Skip to content

Running with Docker

Form Gateway ships with two Dockerfiles and a sample docker-compose.yml:

FileApproachWhen to use
Dockerfile (default)Copies the Linux binary already built on the host into a minimal Alpine runtime imageStandard case: image build in seconds, no toolchain in the build context, exactly the same binary as with every other deployment method
Dockerfile.fullBuilds frontend + backend entirely inside the container (multi-stage: Node LTS → Go 1.x → Alpine)Environments without Go/Node on the host; needs access to the private ORM++ module (build arg/token)

Both produce the same hardened runtime image (Alpine, non-root, health check).

Section titled “Quick start with Docker Compose (recommended)”
Terminal window
make docker-build # build Linux binary (incl. audits) + create image
docker compose up -d
docker compose logs -f # first start: the generated admin password is printed here!

make docker-build does both in one step: first the normal, security-checked build (npm audit → Vite → govulncheck → cross-compile for Linux, architecture auto-detected, overridable via DOCKER_ARCH=arm64), then the seconds-fast image build.

The app is then running at http://localhost:8080. On first start, the following are created in the host folder ./data:

data/
├── config.json configuration (incl. JWT secret and encryption_key — back this up!)
└── gateway.db SQLite database (+ -wal/-shm while running)

Thanks to the bind mount, both files live on the host — inspect, back up, or edit them (after changes: docker compose restart).

Stopping/updating:

Terminal window
docker compose down # stop (data stays in ./data)
make docker-build # build new version
docker compose up -d # start

ORM++ applies schema changes from a new version itself at startup (additive changes automatically, restructurings via the versioned migration steps — see database.md).

Using only the Docker command (without Compose)

Section titled “Using only the Docker command (without Compose)”
Terminal window
make build-linux # or make build-linux-arm64 on ARM hosts
docker build -t form-gateway .

Or built entirely inside the container: docker build -f Dockerfile.full -t form-gateway .

Start the container — with the same hardening options as in the Compose example:

Terminal window
docker run -d \
--name form-gateway \
-p 8080:8080 \
-v "$(pwd)/data:/data" \
--read-only \
--security-opt no-new-privileges \
--cap-drop ALL \
--restart unless-stopped \
form-gateway

Show the first-start admin password, stop, and remove:

Terminal window
docker logs form-gateway | grep -A3 Seeding
docker stop form-gateway
docker rm form-gateway # ./data is preserved

Default Dockerfile: a minimal alpine:3 into which only the Linux binary built on the host is copied (bin/form-gateway-linux-<arch>; ARG TARGETARCH selects it automatically). All security gates and the version injection run in the Makefile build — only the CA certificates and the binary end up in the image. Timezone data is embedded in the Go binary (time/tzdata).

Dockerfile.full: stage 1 builds the frontend (node:lts-alpine), stage 2 the Go binary (golang:1-alpine, govulncheck, CGO_ENABLED=0), stage 3 is the same minimal runtime. Build tools and source code are not included in the final image.

In the image (both Dockerfiles):

MeasureEffect
apk upgrade --no-cachecurrent Alpine security patches at build time
Only ca-certificates installedminimal attack surface (also needed for TLS to SMTP servers and the reputation downloads)
USER app (UID 1000, nologin)the process never runs as root
CGO_ENABLED=0static binary, no libc vulnerabilities
npm audit + govulncheck in the buildvulnerable dependencies abort the build
HEALTHCHECK on /api/v1/healthorchestration detects hung containers

At runtime (Compose/docker run flags):

MeasureEffect
read_only: trueroot filesystem immutable; only /data is writable
no-new-privilegesno privilege escalation
cap_drop: ALLall Linux capabilities removed
Port mapping instead of --network hostown network namespace

TLS: The container speaks HTTP. For public deployments, put a reverse proxy (Caddy, Traefik, nginx) in front — the public API (/v1) and the admin UI run on the same port. Outbound, the container needs access to the SMTP servers of the routes (port 587/465) and — for the reputation refresh — to iptoasn.com and check.torproject.org.

The container writes as UID 1000. On most hosts, the bind mount ./data:/data works directly; if the host UID differs:

Terminal window
mkdir -p data && sudo chown 1000 data

Alternatively, use a named volume:

volumes:
- formgw-data:/data
# ...
volumes:
formgw-data:

Normal configuration happens via data/config.json (created on first start). Environment variables allow container-typical overrides:

VariableMeaningDefault in image
TZtimezone (logs, timestamps), e.g. Europe/BerlinEtc/UTC
FORMGW_HOSTlisten address0.0.0.0 (needed for port mapping)
FORMGW_PORTlisten portvalue from config.json (8080)
FORMGW_DATAdata directory/data

Example (Compose):

environment:
TZ: Europe/Berlin
FORMGW_PORT: "9000"
ports:
- "9000:9000"

PostgreSQL instead of SQLite: set database_dsn in data/config.json (e.g. postgres://formgw:…@db:5432/formgw) — thanks to ORM++, no further changes are needed; run the Postgres instance as an additional Compose service.

The -debug flag also works in the container: docker run … form-gateway -debug (arguments after the image name go to the binary, thanks to ENTRYPOINT).