Docker
Build the workspace into an image and keep responses on a volume.
The image builds the workspace and runs open-form start. Responses and the
signing secret live in .open-form/, so that folder is a volume: the image can
be rebuilt and replaced without touching them.
Add a Dockerfile
Put it in the workspace root, next to package.json.
FROM node:24-slim
WORKDIR /app
RUN npm install -g pnpm@10
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["node_modules/.bin/open-form", "start"]CMD runs the CLI directly rather than through pnpm start, so docker stop
reaches the server and it shuts down cleanly.
Keep local data out of the image
node_modules
dist
.git
.open-form
.env
.env.*
/responses*Without it, COPY . . copies your local .open-form/ into the image: the
responses you collected while developing, and the secret.
Build and run
docker build -t my-forms .
docker run -d --name my-forms --restart unless-stopped -p 3000:3000 \
-v my-forms-data:/app/.open-form \
-e OPEN_FORM_ADMIN_PASSWORD='a long passphrase you choose' \
my-forms-v my-forms-data:/app/.open-form creates the named volume on the first run and
reuses it after. Forms are at http://localhost:3000/f/<id> and Studio at
/studio.
On a server with Compose
Keep the variables in a .env next to compose.yaml on the server. The
.dockerignore above keeps it out of the image.
services:
forms:
build: .
ports:
- '127.0.0.1:3000:3000'
env_file: .env
volumes:
- data:/app/.open-form
restart: unless-stopped
volumes:
data:OPEN_FORM_ADMIN_PASSWORD='a long passphrase you choose'
OPEN_FORM_TRUST_PROXY=1docker compose up -d --buildRun the same command after pulling new code: it rebuilds the image and replaces
the container, and the data volume carries the responses over.
The port is bound to 127.0.0.1 so the only way in is the reverse proxy that
ends HTTPS on the host (Caddy, nginx), which is why OPEN_FORM_TRUST_PROXY is
1. If clients could reach port 3000 directly, they could send their own
X-Forwarded-For and pick the address rate limits count. Without a proxy,
publish 3000:3000 and leave OPEN_FORM_TRUST_PROXY out. See
HTTPS and proxies.
The volume
The volume holds open-form.db, its -wal and -shm files, and secret.
Set OPEN_FORM_SECRET instead to keep the secret off the volume, so a copy of
the volume doesn't carry it; see Production.
Besides Studio's export, you can export from the running container:
docker exec my-forms node_modules/.bin/open-form responses export <id> > responses.csv