Running with Docker
Form Gateway ships with two Dockerfiles and a sample docker-compose.yml:
| File | Approach | When to use |
|---|---|---|
| Dockerfile (default) | Copies the Linux binary already built on the host into a minimal Alpine runtime image | Standard case: image build in seconds, no toolchain in the build context, exactly the same binary as with every other deployment method |
| Dockerfile.full | Builds 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).
Quick start with Docker Compose (recommended)
Section titled “Quick start with Docker Compose (recommended)”make docker-build # build Linux binary (incl. audits) + create imagedocker compose up -ddocker 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:
docker compose down # stop (data stays in ./data)make docker-build # build new versiondocker compose up -d # startORM++ 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)”make build-linux # or make build-linux-arm64 on ARM hostsdocker 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:
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-gatewayShow the first-start admin password, stop, and remove:
docker logs form-gateway | grep -A3 Seedingdocker stop form-gatewaydocker rm form-gateway # ./data is preservedHow the image is built
Section titled “How the image is built”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.
Security hardening in detail
Section titled “Security hardening in detail”In the image (both Dockerfiles):
| Measure | Effect |
|---|---|
apk upgrade --no-cache | current Alpine security patches at build time |
Only ca-certificates installed | minimal 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=0 | static binary, no libc vulnerabilities |
npm audit + govulncheck in the build | vulnerable dependencies abort the build |
HEALTHCHECK on /api/v1/health | orchestration detects hung containers |
At runtime (Compose/docker run flags):
| Measure | Effect |
|---|---|
read_only: true | root filesystem immutable; only /data is writable |
no-new-privileges | no privilege escalation |
cap_drop: ALL | all Linux capabilities removed |
Port mapping instead of --network host | own 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.
Data volume permissions
Section titled “Data volume permissions”The container writes as UID 1000. On most hosts, the bind mount
./data:/data works directly; if the host UID differs:
mkdir -p data && sudo chown 1000 dataAlternatively, use a named volume:
volumes: - formgw-data:/data# ...volumes: formgw-data:Configuration inside the container
Section titled “Configuration inside the container”Normal configuration happens via data/config.json (created on
first start). Environment variables allow container-typical
overrides:
| Variable | Meaning | Default in image |
|---|---|---|
TZ | timezone (logs, timestamps), e.g. Europe/Berlin | Etc/UTC |
FORMGW_HOST | listen address | 0.0.0.0 (needed for port mapping) |
FORMGW_PORT | listen port | value from config.json (8080) |
FORMGW_DATA | data 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).