Best Practices for Developing with the Openship Packages: A Complete Technical Guide

Develop with openship by respecting its three-layer architecture (runtime, infra, system), using the platform factory to compose deployment-specific adapters, and following PNPM workspace conventions with strict TypeScript and Drizzle ORM patterns.

Openship is a monorepo deployment platform maintained by oblien that bundles a CLI, web dashboard, and desktop application. Following best practices for developing with the openship packages ensures your code remains compatible across cloud, self-hosted, and desktop deployment targets while leveraging the platform's factory-based composition model.

Understanding the Three-Layer Architecture

Openship organizes functionality into three distinct layers that are composed by a platform factory. Understanding these boundaries is essential for writing maintainable code.

Runtime Layer

The runtime layer handles container and process lifecycle operations. Located in packages/adapters/src/runtime/, it defines the RuntimeAdapter interface implemented by DockerRuntime, BareRuntime, and CloudRuntime. These classes manage build, start, stop, and destroy operations according to the deployment target.

When extending openship, implement the RuntimeAdapter interface in packages/adapters/src/runtime/types.ts rather than modifying concrete implementations directly. This preserves compatibility with the factory pattern used in packages/adapters/src/platform.ts.

Infra Layer

The infra layer manages reverse-proxy routing and TLS certificate provisioning. Key types include RoutingProvider and SslProvider, with concrete implementations like NginxProvider in packages/adapters/src/infra/nginx.ts and CloudInfraProvider for managed environments.

Keep infra concerns separate from runtime logic. The NginxProvider handles certificatebot integration and route registration without direct knowledge of container internals, ensuring clean separation between how services run and how they are exposed.

System Layer

The system layer performs prerequisite checks and tool installation. The SystemManager class in packages/adapters/src/system/setup.ts coordinates CommandExecutor to verify Docker, Traefik, Git, and other dependencies before deployment begins.

Use SystemManager for any prerequisite validation rather than implementing custom checks. This ensures consistent behavior across CLI and desktop environments.

Platform Factory Composition

These layers assemble via the factory function in packages/adapters/src/platform.ts:

export async function createPlatform(config: PlatformConfig): Promise<Platform> {
  switch (config.target) {
    case "cloud":       return createCloudPlatform(config);
    case "desktop":     return createDesktopPlatform(config);
    case "selfhosted":  return createSelfHostedPlatform(config);
  }
}

The factory resolves the appropriate runtime/infra/system combination at startup based on CLOUD_MODE and DEPLOY_MODE environment variables. This enables tree-shaking of unused code while supporting three distinct deployment targets from a single codebase.

Openship uses PNPM workspaces defined in pnpm-workspace.yaml:

packages:
  - "apps/*"
  - "packages/*"

The repository divides into:

  • apps/ – Entry points including cli, dashboard, desktop, api, email, and web
  • packages/ – Reusable libraries: adapters, core, db, db-email, onboarding, ui

All packages extend the root tsconfig.base.json, enforcing consistent compiler options and strict TypeScript checking across the entire repository. When adding new packages, ensure they inherit from this base configuration to maintain type compatibility.

Implementing Database Changes with Drizzle ORM

The database layer uses Drizzle ORM with schemas defined in packages/db/src/schema/. Each table resides in its own TypeScript file, such as packages/db/src/schema/service.ts:

export const service = pgTable("service", {
  id: serial("id").primaryKey(),
  name: text("name").notNull(),
  // …
});

Follow this three-step pattern when adding new models:

  1. Create a schema file under packages/db/src/schema/
  2. Add a corresponding repository under packages/db/src/repos/ (e.g., service.repo.ts)
  3. Write unit tests alongside the repository (e.g., service.repo.test.ts)

Repository files provide typed query helpers used throughout the platform. For example:

// packages/db/src/repos/new-entity.repo.ts
import { db } from "../client";
import { newEntity } from "../schema/new-entity";

export const newEntityRepo = {
  async create(data: { name: string }) {
    return await db.insert(newEntity).values(data).returning();
  },

  async findById(id: number) {
    return await db.select().from(newEntity).where(eq(newEntity.id, id));
  },
};

Building CLI and API Features

The CLI in apps/cli serves as a thin wrapper around the platform API. Commands like openship init and openship deploy invoke the same core logic used by the web dashboard (apps/api), ensuring behavioral parity between interfaces.

Reusing Validation Logic

The onboarding package centralizes user-facing validation. Functions like validateServerAddress in packages/onboarding/src/validation.ts enforce consistent input handling:

export function validateServerAddress(ip: string): string | null {
  if (!ip) return "Please enter your server IP address";
  if (!IP_HOSTNAME_RE.test(ip)) return "That doesn't look like a valid IP address";
  return null;
}

Import these validators in custom CLI commands or UI components rather than duplicating regex patterns. For SSH payload validation, use validateSshPayload from the same module:

import { validateSshPayload } from "@repo/onboarding";

function handleUserInput(payload) {
  const err = validateSshPayload(payload);
  if (err) {
    console.error(err);
    process.exit(1);
  }
  // Continue with verified payload…
}

Configuring Environment and Deployment Targets

Environment variables drive platform behavior:

  • CLOUD_MODE (true/false) – Forces cloud runtime, overriding other settings
  • DEPLOY_MODE (docker/bare/cloud/desktop) – Selects the runtime and target combination

The resolution order is documented in packages/adapters/docs/ARCHITECTURE.md. The platform factory reads these variables during initPlatform() to instantiate the correct adapter combination. Never hardcode deployment-specific logic; instead, rely on the factory to inject the appropriate runtime and infra providers.

Maintaining Code Quality and Testing Standards

Openship enforces strict development standards across all workspaces:

  • Strict TypeScript – All packages compile with strict: true, noImplicitAny, and exactOptionalPropertyTypes
  • Unit tests – Co-locate tests with source code using the *.test.ts naming convention
  • Integration tests – Located under apps/api/test/modules
  • Conventional commits – Follow the Conventional Commits specification (e.g., feat: add new service)
  • Formatting – Run bun format before committing to ensure Prettier compliance

Use bun dev:<workspace> (e.g., bun dev:api) to spin up individual workspaces during development. This conserves resources while allowing targeted testing of specific components.

Summary

  • Respect the three-layer architecture (runtime, infra, system) and compose components through the platform factory in packages/adapters/src/platform.ts
  • Add database tables using Drizzle ORM schemas in packages/db/src/schema/ with corresponding repositories in packages/db/src/repos/
  • Reuse validation utilities from packages/onboarding/src/validation.ts to maintain consistent input handling across CLI and web interfaces
  • Configure deployment targets via CLOUD_MODE and DEPLOY_MODE environment variables rather than hardcoding environment checks
  • Maintain strict TypeScript compliance and conventional commit standards across all PNPM workspaces

Frequently Asked Questions

How does the openship platform factory work?

The platform factory in packages/adapters/src/platform.ts exports createPlatform(), which instantiates the appropriate combination of runtime, infra, and system adapters based on the PlatformConfig target. It returns either createCloudPlatform(), createDesktopPlatform(), or createSelfHostedPlatform(), ensuring the correct providers are injected for the specific deployment environment while keeping unused implementations tree-shaken from the bundle.

Where should I add new database tables in openship?

Create a new schema file under packages/db/src/schema/ using Drizzle ORM's pgTable definitions, then add a corresponding repository file under packages/db/src/repos/ that exports typed query helpers. Follow the existing pattern of co-locating unit tests (e.g., new-entity.repo.test.ts) to ensure data access logic remains tested and maintainable.

How do I validate user input consistently across openship packages?

Import validation functions from packages/onboarding/src/validation.ts, which exports utilities like validateServerAddress() and validateSshPayload(). These functions enforce regex patterns and security checks used by both the CLI and web dashboard, ensuring consistent error messaging and input sanitization across all user interfaces.

What is the difference between runtime and infra layers in openship?

The runtime layer (packages/adapters/src/runtime/) manages container and process lifecycles (build, start, stop), while the infra layer (packages/adapters/src/infra/) handles external routing and SSL certificate provisioning. Runtime adapters know how services execute, whereas infra providers know how to expose them to the network, maintaining separation between execution and connectivity concerns.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →