Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .changeset/official-docker-runtime-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/cli': minor
---

feat(docker): official runtime image `ghcr.io/objectstack-ai/objectstack`

ObjectStack now ships an official, versioned Docker runtime image instead of
only a copy-me example. The image packages Node 22 + a pinned
`@objectstack/cli` + `os start` (non-root `node` user, `/api/v1/health`
HEALTHCHECK, `OS_ARTIFACT_PATH` / `OS_PORT=8080` preset), published
multi-arch (amd64/arm64) on every release with tags mirroring the CLI
version (`X.Y.Z` / `X.Y` / `X` / `latest`).

Deploying an app is now:

```dockerfile
FROM ghcr.io/objectstack-ai/objectstack:<version>
COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json
```

or, with no image build at all, `docker run` the official image with the
artifact mounted (or `OS_ARTIFACT_PATH` pointing at an `https://` URL).
`examples/docker` and the Self-Hosted Deployment docs now build on the
official image; the self-built runtime Dockerfile remains documented for
air-gapped registries.
104 changes: 104 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Docker Publish

# Builds and pushes the official runtime image
# ghcr.io/objectstack-ai/objectstack from docker/Dockerfile.
#
# Two entry points:
# - workflow_call: invoked by release.yml right after a successful
# `changeset publish`, with the just-published @objectstack/cli version.
# (A plain `on: push: tags:` trigger would never fire — the release
# workflow pushes its tags with GITHUB_TOKEN, and GitHub suppresses
# workflow triggers from GITHUB_TOKEN-pushed refs.)
# - workflow_dispatch: manual backfill / rebuild of an already-published
# version, e.g. to pick up node:22-slim base-image CVE patches between
# framework releases.
#
# The image tag always equals the @objectstack/cli version baked inside.

on:
workflow_call:
inputs:
version:
description: 'Published @objectstack/cli version to package (e.g. 14.8.0)'
required: true
type: string
workflow_dispatch:
inputs:
version:
description: '@objectstack/cli version to package (e.g. 14.8.0) — must already be on npm'
required: true
type: string

permissions:
contents: read

jobs:
publish:
name: Build & push ghcr.io/objectstack-ai/objectstack
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/objectstack-ai/objectstack
steps:
- name: Checkout repository
uses: actions/checkout@v7

- name: Validate version input
id: version
env:
VERSION: ${{ inputs.version }}
run: |
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "::error::'$VERSION' is not a valid semver version"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
# Tag = CLI version. Rolling minor/major/latest tags move with every
# publish; prereleases (x.y.z-rc.1) get only their exact tag.
tags: |
type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.version }}
type=semver,pattern={{major}},value=${{ steps.version.outputs.version }}
type=raw,value=latest,enable=${{ !contains(steps.version.outputs.version, '-') }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: docker
platforms: linux/amd64,linux/arm64
push: true
build-args: |
OS_CLI_VERSION=${{ steps.version.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

- name: Smoke-test the pushed image (amd64)
# `os --version` proves the CLI resolved, installed, and runs on the
# pushed image; a boot test needs an artifact + DB and belongs to the
# examples/e2e suites, not here.
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
docker pull "$IMAGE:$VERSION"
docker run --rm "$IMAGE:$VERSION" os --version
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jobs:
permissions:
contents: write
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
cli-version: ${{ steps.cli-version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
Expand Down Expand Up @@ -94,3 +97,34 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Extract published @objectstack/cli version
id: cli-version
if: steps.changesets.outputs.published == 'true'
# The fixed group bumps every public package in lockstep, so the CLI
# is always in publishedPackages when a publish happened. Passed via
# env (not inline interpolation) to avoid shell-quoting the JSON.
env:
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
run: |
version=$(jq -r '.[] | select(.name=="@objectstack/cli") | .version' <<<"$PUBLISHED")
if [ -z "$version" ]; then
echo "::error::publish succeeded but @objectstack/cli is missing from publishedPackages"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

docker:
name: Docker image
needs: release
# Publish the official runtime image (ghcr.io/objectstack-ai/objectstack)
# for every npm release. Called as a reusable workflow so the same build
# can be re-run manually via workflow_dispatch (e.g. base-image CVE
# rebuilds) — see docker-publish.yml.
if: needs.release.outputs.published == 'true'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker-publish.yml
with:
version: ${{ needs.release.outputs.cli-version }}
14 changes: 13 additions & 1 deletion .github/workflows/scaffold-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ on:
paths:
- 'packages/create-objectstack/**'
- 'examples/docker/**'
- 'docker/**'
- '.github/workflows/scaffold-e2e.yml'
schedule:
- cron: '23 3 * * *'
Expand Down Expand Up @@ -114,13 +115,24 @@ jobs:
curl -fsS http://localhost:8080/api/v1/ready
kill "$SERVER_PID"

- name: Build official runtime image from this checkout
# examples/docker builds FROM ghcr.io/objectstack-ai/objectstack.
# Build that base HERE from docker/Dockerfile instead of pulling, so
# (a) PRs exercise the official runtime Dockerfile itself, and
# (b) the e2e stays hermetic — no dependency on a prior release
# having published the tag (chicken-and-egg on the very first one).
run: |
docker build -t ghcr.io/objectstack-ai/objectstack:latest \
--build-arg OS_CLI_VERSION=latest \
"$GITHUB_WORKSPACE/docker"

- name: Docker build and run (examples/docker)
run: |
cd "$RUNNER_TEMP/e2e-app"
cp "$GITHUB_WORKSPACE"/examples/docker/Dockerfile \
"$GITHUB_WORKSPACE"/examples/docker/docker-compose.yml \
"$GITHUB_WORKSPACE"/examples/docker/.dockerignore .
docker build -t e2e-app .
docker build --pull=false -t e2e-app .
docker run -d --name e2e -p 18080:8080 \
-e OS_SECRET_KEY="$(openssl rand -hex 32)" \
-e OS_AUTH_SECRET="$(openssl rand -hex 32)" \
Expand Down
67 changes: 51 additions & 16 deletions content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,38 @@ curl -fsS http://localhost:8080/api/v1/health
Upgrades are atomic: replace the artifact file and restart the service. Roll
back by restoring the previous artifact.

## Option 2 — Docker
## Option 2 — Docker (official image)

The artifact model maps cleanly onto containers: the image contains Node, the
CLI, and one JSON file. The Dockerfile below (plus the compose stack in the
next section and a `.dockerignore`) also ships ready-to-copy in
The artifact model maps cleanly onto containers, and ObjectStack ships an
**official runtime image** for exactly this:
`ghcr.io/objectstack-ai/objectstack` — Node 22 + `@objectstack/cli` +
`os start`, running as a non-root user with a built-in health check and
`OS_ARTIFACT_PATH` / `OS_PORT=8080` preset. Image tags mirror
`@objectstack/cli` versions (`14.8.0`, `14.8`, `14`, `latest`) and the image
is published multi-arch (amd64/arm64) on every framework release — **pin the
exact version in production**, matching the CLI version in your
`package.json`.

The fastest path needs no image build at all — hand the official image your
compiled artifact:

```bash
os build # → dist/objectstack.json (or in CI)

docker run -p 8080:8080 \
-v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET \
-e OS_SECRET_KEY \
ghcr.io/objectstack-ai/objectstack:14.8.0
```

(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
straight from release storage instead of a mount.)

For a self-contained deployable image, extend it. The Dockerfile below (plus
the compose stack in the next section and a `.dockerignore`) ships
ready-to-copy in
[`examples/docker`](https://github.com/objectstack-ai/framework/tree/main/examples/docker)
— drop the files into your scaffolded project.

Expand All @@ -102,11 +129,28 @@ RUN npm ci
COPY . .
RUN npx os build # → dist/objectstack.json

# ── Runtime stage: CLI + artifact only ───────────────────────────────
# ── Runtime: the official ObjectStack runtime image ──────────────────
FROM ghcr.io/objectstack-ai/objectstack:14.8.0
COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json
```

```bash
docker build -t my-app .
docker run -p 8080:8080 \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET \
-e OS_SECRET_KEY \
my-app
```

Prefer to build the runtime yourself (air-gapped registry, custom base
image)? The official image is nothing more than:

```dockerfile title="Dockerfile (self-built runtime, equivalent)"
FROM node:22-slim
WORKDIR /srv/app
RUN npm install -g @objectstack/cli
COPY --from=build /app/dist/objectstack.json ./objectstack.json
RUN npm install -g @objectstack/cli@14.8.0
COPY dist/objectstack.json ./objectstack.json

ENV NODE_ENV=production \
OS_ARTIFACT_PATH=/srv/app/objectstack.json \
Expand All @@ -119,15 +163,6 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
CMD ["os", "start"]
```

```bash
docker build -t my-app .
docker run -p 8080:8080 \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET \
-e OS_SECRET_KEY \
my-app
```

<Callout type="warn">
**Never bake `OS_AUTH_SECRET` / `OS_SECRET_KEY` into the image.** Pass them at
runtime from your orchestrator's secret store. And never rely on the
Expand Down
54 changes: 54 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ObjectStack official runtime image — ghcr.io/objectstack-ai/objectstack
#
# A generic, app-agnostic production runtime: Node + @objectstack/cli +
# `os start`. It contains NO app — bring your compiled artifact
# (dist/objectstack.json, built by `os build` in CI):
#
# FROM ghcr.io/objectstack-ai/objectstack:<version>
# COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json
#
# or run it without any image build at all:
#
# docker run -p 8080:8080 \
# -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \
# -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
# -e OS_AUTH_SECRET -e OS_SECRET_KEY \
# ghcr.io/objectstack-ai/objectstack:<version>
#
# OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can be
# fetched from your release storage instead of copied in.
#
# Published by .github/workflows/docker-publish.yml on every framework
# release; the image tag always matches the @objectstack/cli version inside.
# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting

FROM node:22-slim

# Pinned by CI to the @objectstack/cli release that triggered the publish.
# `latest` is only the fallback for ad-hoc local builds of this file.
ARG OS_CLI_VERSION=latest
RUN npm install -g @objectstack/cli@${OS_CLI_VERSION} \
&& npm cache clean --force

LABEL org.opencontainers.image.source="https://github.com/objectstack-ai/framework" \
org.opencontainers.image.title="ObjectStack" \
org.opencontainers.image.description="Official ObjectStack runtime: os start + your compiled objectstack.json artifact" \
org.opencontainers.image.licenses="Apache-2.0"

WORKDIR /srv/app
RUN chown node:node /srv/app
USER node

ENV NODE_ENV=production \
OS_ARTIFACT_PATH=/srv/app/objectstack.json \
OS_PORT=8080
EXPOSE 8080

# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready.
# No backticks/$ in the -e script — this CMD runs under `/bin/sh -c`.
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
CMD node -e "fetch('http://localhost:'+(process.env.OS_PORT||8080)+'/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"

# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime —
# never bake them into an image.
CMD ["os", "start"]
Loading
Loading