Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.git
.github
.DS_Store
.claude-sessions
.cursor
.task
.od
.ocd
node_modules
dist
coverage
*.log
tsconfig.tsbuildinfo
deploy/*.env
deploy/.env
deploy/**/*.tar
deploy/**/*.tgz
deploy/**/*.zip
docs
story
7 changes: 5 additions & 2 deletions apps/daemon/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { startServer } from './server.js';

const args = process.argv.slice(2);
let port = Number(process.env.OD_PORT) || 7456;
let host = process.env.OD_HOST || '127.0.0.1';
let open = true;

for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '-p' || a === '--port') {
port = Number(args[++i]);
} else if (a === '--host') {
host = args[++i] || host;
} else if (a === '--no-open') {
open = false;
} else if (a === '-h' || a === '--help') {
console.log(`Usage: od [--port <n>] [--no-open]
console.log(`Usage: od [--host <addr>] [--port <n>] [--no-open]

Starts a local daemon that:
* scans PATH for installed code-agent CLIs (claude, codex, gemini, opencode, cursor-agent, ...)
Expand All @@ -23,7 +26,7 @@ Starts a local daemon that:
}
}

startServer({ port }).then(url => {
startServer({ host, port }).then(url => {
console.log(`[od] listening on ${url}`);
if (open) {
const opener = process.platform === 'darwin' ? 'open'
Expand Down
11 changes: 8 additions & 3 deletions apps/daemon/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ function sendMulterError(res, err) {
return res.status(500).json({ code: 'UPLOAD_ERROR', error: 'upload failed' });
}

function formatListenHost(host) {
const displayHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
return displayHost.includes(':') && !displayHost.startsWith('[') ? `[${displayHost}]` : displayHost;
}

export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {}) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
Expand Down Expand Up @@ -228,7 +233,7 @@ export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INT
};
}

export async function startServer({ port = 7456, returnServer = false } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7456, returnServer = false } = {}) {
const app = express();
app.use(express.json({ limit: '4mb' }));
const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR });
Expand Down Expand Up @@ -1371,10 +1376,10 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
}

return new Promise((resolve) => {
const server = app.listen(port, '127.0.0.1', () => {
const server = app.listen(port, host, () => {
const address = server.address();
const actualPort = typeof address === 'object' && address ? address.port : port;
const url = `http://127.0.0.1:${actualPort}`;
const url = `http://${formatListenHost(host)}:${actualPort}`;
resolve(returnServer ? { url, server } : url);
});
});
Expand Down
12 changes: 12 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Image published by deploy/scripts/publish-images.sh.
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest

# Host port exposed by docker compose.
OPEN_DESIGN_PORT=7456

# Container memory limit. The idle service has been verified around 18-22 MiB.
# Raise this for large exports, concurrent agent runs, or heavy upload workflows.
OPEN_DESIGN_MEM_LIMIT=384m

# Node.js heap cap inside the container.
NODE_OPTIONS=--max-old-space-size=192
90 changes: 90 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
ARG NODE_IMAGE=docker.io/library/node:24-alpine
ARG RUNTIME_IMAGE=docker.io/library/node:24-alpine

FROM ${NODE_IMAGE} AS build

ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG http_proxy
ARG https_proxy
ARG no_proxy
ARG NO_PROXY

ENV HTTP_PROXY=${HTTP_PROXY}
ENV HTTPS_PROXY=${HTTPS_PROXY}
ENV http_proxy=${http_proxy}
ENV https_proxy=${https_proxy}
ENV no_proxy=${no_proxy}
ENV NO_PROXY=${NO_PROXY}
ENV CI=true

RUN apk add --no-cache python3 make g++

WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/daemon/package.json ./apps/daemon/package.json
COPY apps/web/package.json ./apps/web/package.json
COPY e2e/package.json ./e2e/package.json
RUN corepack enable && \
corepack prepare pnpm@10.33.2 --activate && \
pnpm install --frozen-lockfile

COPY apps ./apps
RUN pnpm --filter @open-design/web build && \
pnpm --filter @open-design/daemon deploy --legacy --prod /app/deploy/daemon && \
pnpm store prune && \
rm -rf \
/root/.cache \
/root/.local/share/pnpm/store \
/app/deploy/daemon/node_modules/.cache \
/app/deploy/daemon/node_modules/@types \
/app/deploy/daemon/node_modules/.pnpm/@types+* \
/app/deploy/daemon/node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3/deps \
/app/deploy/daemon/node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3/src && \
find /app/deploy/daemon/node_modules -type d \( \
-name test -o \
-name tests -o \
-name "__tests__" -o \
-name docs -o \
-name doc -o \
-name example -o \
-name examples -o \
-name ".github" \
\) -prune -exec rm -rf '{}' + && \
find /app/deploy/daemon/node_modules -type f \( \
-name "*.md" -o \
-name "*.markdown" -o \
-name "*.d.ts" -o \
-name "*.d.cts" -o \
-name "*.d.mts" -o \
-name "*.map" -o \
-name "*.tsbuildinfo" -o \
-name "binding.gyp" \
\) -delete

FROM ${RUNTIME_IMAGE}

RUN apk add --no-cache tini && \
addgroup -S -g 1001 open-design && \
adduser -S -D -H -u 1001 -G open-design open-design

WORKDIR /app
COPY --from=build --chown=open-design:open-design /app/deploy/daemon ./apps/daemon
COPY --from=build --chown=open-design:open-design /app/apps/web/out ./apps/web/out
COPY --chown=open-design:open-design skills ./skills
COPY --chown=open-design:open-design design-systems ./design-systems
COPY --chown=open-design:open-design assets/frames ./assets/frames

RUN mkdir -p /app/.od && \
chown -R open-design:open-design /app

ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=192
ENV OD_HOST=0.0.0.0
ENV OD_PORT=7456

EXPOSE 7456

USER open-design
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "apps/daemon/cli.js", "--no-open"]
48 changes: 48 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Docker deployment

This deployment ships Open Design as a single Alpine-based runtime image. The
daemon serves both the API and the built Next.js static export, so there is no
separate nginx container.

## Local compose

```bash
cd deploy
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest docker compose pull
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest docker compose up -d --no-build
```

Defaults:

- Host port: `7456` (`OPEN_DESIGN_PORT=8080` to override)
- Runtime data volume: `open_design_data` mounted at `/app/.od`
- Node heap cap: `--max-old-space-size=192`
- Compose memory cap: `384m` (`OPEN_DESIGN_MEM_LIMIT=256m` to override)

The image intentionally does not bundle Claude/Codex/Gemini CLI binaries. Keep
those outside the image, or build a separate private runtime layer if a server
deployment needs local code-agent CLIs installed in the container.

## Publish to Docker Hub

```bash
deploy/scripts/publish-images.sh --image_tag latest
```

Useful overrides:

```bash
IMAGE_NAMESPACE=your-dockerhub-user deploy/scripts/publish-images.sh --arch arm64
deploy/scripts/publish-images.sh --image docker.io/your-user/open-design:0.1.0
```

The script defaults to:

- `docker.io/vanjayak/open-design:<tag>`
- `linux/amd64,linux/arm64`
- `skopeo` push strategy with Docker credentials read from `~/.docker/config.json`
- preloading base images through `skopeo` to reduce Docker Hub pull flakiness

If `127.0.0.1:7890` is available and no proxy is already set, the script uses it
for registry access and passes `host.docker.internal:7890` into Docker builds. The
host-gateway alias is only added for builds that need this local proxy mapping.
41 changes: 41 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: open-design

services:
open-design:
container_name: open-design
image: ${OPEN_DESIGN_IMAGE:-docker.io/vanjayak/open-design:latest}
build:
context: ..
dockerfile: deploy/Dockerfile
restart: always
environment:
NODE_ENV: production
NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=192}
OD_HOST: 0.0.0.0
OD_PORT: 7456
ports:
- "${OPEN_DESIGN_PORT:-7456}:7456"
volumes:
- open_design_data:/app/.od
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
mem_limit: ${OPEN_DESIGN_MEM_LIMIT:-384m}
pids_limit: 256
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:7456/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

volumes:
open_design_data:
Loading