Reusable orchestration and API adapters for building and deploying containerized projects.
@mittwald/container-deploy is a TypeScript library that provides a unified interface for orchestrating Docker-based application deployments with the Mittwald API. It handles the complete deployment pipeline including Docker image building, registry management, and service deployment.
The library is organized around a three-tier architecture that cleanly separates concerns and enables flexible composition:
- Entities (Tier 1) – Single-responsibility API wrappers and operations
- Orchestration (Tier 2) – Multi-step workflows coordinating entities
- Consumers (Tier 3) – Applications using either tier depending on needs
This architecture enables both high-level deployment (deployProject()) and fine-grained control over individual operations.
- Modular Architecture – Three-tier design with clear separation of concerns
- Docker Integration – Seamless Docker image building and pushing
- Registry Management – Automatic registry setup and configuration
- Service Deployment – Quick and reliable service deployment
- Secure Registry Setup – Generates registry credentials during orchestration
- Duration Handling – Flexible timeout and scheduling utilities
- TypeScript First – Fully typed API for excellent IDE support
- Well Tested – Comprehensive jest test suite included
npm install @mittwald/container-deploy- Node.js 18+
- TypeScript 5.9+
- Docker (for image building)
- Mittwald API Client credentials
┌─────────────────────────────────────────────────────┐
│ Tier 3: Consumers │
│ (CLI, CI/CD, external tools use either tier below) │
└──────────┬──────────────────────────────────────────┘
│
┌────┴─────────────────────────────────┐
│ │
┌─────▼──────────────────────┐ ┌────────────▼──────────────────┐
│ Tier 2: Orchestration │ │ Tier 1: Entities (Direct) │
│ Multi-step workflows │ │ Single-responsibility ops │
│ (deploy_project.ts, │ │ (registry.ts, domain.ts, │
│ registry_setup.ts) │ │ service.ts, docker.ts) │
└────────────┬───────────────┘ │ │
│ │ Can be used independently │
┌─────▼──────────────────┤ for fine-grained control │
│ │ │
│ Compose & delegate └──────────────────────────────┘
│
┌──────▼─────────────────────────────────────────────────────┐
│ External Dependencies (@mittwald/api-client, Docker CLI) │
└───────────────────────────────────────────────────────────┘
Pure API wrappers with no orchestration logic. Each handles a single domain:
- Responsibility: Container registry operations via Mittwald API
- Key Functions:
getProjectRegistry()– Fetch existing registry for a projectcreateRegistry()– Create new registry servicecheckProjectRegistry(apiClient, projectId, stackId, registry)– Read and verify registry credentials from the selected stack
- Used By:
registry_setup.ts, direct consumers for registry-only operations
- Responsibility: Service creation and deployment via Mittwald API
- Key Functions:
deployService(apiClient, projectId, stackId, repositoryData, timeout, environment?, serviceName?, volumes?)– Deploy a standard service (returns full DeployRes)deployServiceAs(apiClient, projectId, stackId, serviceName, serviceConfig, timeout)– Deploy any named service with custom config (returns service ID string)
- Used By:
registry_setup.tsfor registry service,deploy_project.tsfor application service - Example: Registry setup uses
deployServiceAs()to create the container registry service
- Responsibility: Domain creation and ingress readiness checking
- Key Functions:
createDomain()– Create ingress for a servicewaitForDomainReachable()– Poll until domain IPs assigned and TLS certificate createdwaitForIngressReady()– Semantic alias for clarity in orchestration contextscreateAndWaitForDomain()– Combine domain creation and waiting
- Used By:
registry_setup.tsto expose registry, direct consumers for domain operations
- Responsibility: Docker image building, checking, and pushing
- Key Functions:
checkDocker()– Verify Docker is installed and runningcheckRailpack()– Verify Railpack build tool is availablelocalDockerBuild()– Build Docker image locallylocalBuildWithRailpack()– Build with Mittwald's Railpack toolbuildDockerImage()– Build with Railpack when a plan exists, otherwise use the repository DockerfilelocalDockerPush()– Push image to registry
- Used By:
deploy_project.tsfor image building/pushing
Multi-step workflows that compose entities to achieve higher-level goals:
- Responsibility: Full registry setup pipeline (service → domain → registration)
- Key Function:
setupProjectRegistry()executes 6 steps:- Check if registry already exists
- Create registry service via
service.ts - Create and wait for registry domain via
domain.ts - Wait 2 minutes for DNS/TLS propagation (documented Mittwald platform behavior)
- Register in Mittwald API via
registry.ts - Return registry details
- Uses: All entity modules (registry, service, domain)
- Pattern: Step orchestration with error handling and timeouts
- Responsibility: Full end-to-end deployment (registry → build → push → deploy)
- Key Function:
deployProject()executes sequence:- Validate environment
- Set up project registry via
registry_setup.ts - Build Docker image via
docker.ts - Push image to registry via
docker.ts - Deploy service via
service.ts
- Uses: All entity modules plus registry orchestration
- Entry Point: Primary API exported for consumer use
A project no longer has an implicit default stack. Create or select existing
stacks belonging to the project and pass their IDs explicitly. The library does
not create stacks or infer their IDs from projectId.
This is a breaking API change:
deployProject()acceptsstackIdto use one stack for both the registry and application, orregistryStackIdandapplicationStackIdto deploy them into separate stacks. WhenstackIdis supplied with either specific ID, the specific ID overrides the shared stack for that part.deployService(apiClient, projectId, stackId, repositoryData, timeout, environment?, serviceName?, volumes?)deployServiceAs(apiClient, projectId, stackId, serviceName, serviceConfig, timeout)setupProjectRegistry(apiClient, projectId, stackId, projectShortId, timeout)checkProjectRegistry(apiClient, projectId, stackId, registry)
The registry service uses the registry stack, while the application service uses
the application stack. When reusing an existing project registry, its
project-registry service must be in the selected registry stack. Registry
records and domains remain project-scoped; their APIs still use projectId.
Service readiness is checked by service ID, so a same-named service in another
stack cannot satisfy the deployment's readiness check.
import { deployProject, Duration } from '@mittwald/container-deploy';
// Deploy entire project (registry setup + build + push + service)
const result = await deployProject({
apiClient: myApiClient,
projectId: 'your-project-id',
stackId: 'your-stack-id',
waitTimeout: Duration.fromSeconds(600),
});
console.log(`✓ Deployed: ${result.serviceName} (${result.deployedServiceId})`);import {
setupProjectRegistry,
buildDockerImage,
checkRepository,
localDockerPush,
Duration,
} from '@mittwald/container-deploy';
// Set up registry with custom timeout
const registry = await setupProjectRegistry(
apiClient,
projectId,
stackId,
projectShortId,
Duration.fromSeconds(300)
);
// Then handle build/push with your own logic
const repositoryData = await checkRepository();
const builtRepositoryData = await buildDockerImage(registry, repositoryData);
await localDockerPush(builtRepositoryData, registry);import {
setupProjectRegistry,
checkRepository,
buildDockerImage,
localDockerPush,
deployService,
Duration,
} from '@mittwald/container-deploy';
const registry = await setupProjectRegistry(apiClient, projectId, stackId, projectShortId, Duration.fromSeconds(300));
const repositoryData = await checkRepository();
const builtRepositoryData = await buildDockerImage(registry, repositoryData);
await localDockerPush(builtRepositoryData, registry);
await deployService(apiClient, projectId, stackId, builtRepositoryData, Duration.fromSeconds(600));Complete deployment pipeline: registry setup → Docker build → push → service deployment.
Parameters:
apiClient– Mittwald API v2 client instanceprojectId– UUID of target projectstackId– ID of an existing stack belonging to the project; use this for both services, or as the default when only one service-specific ID is setregistryStackId– ID of the existing stack hosting the registry serviceapplicationStackId– ID of the existing stack hosting the application servicewaitTimeout– Maximum time to wait for operationsimageName(optional) – Name of the built image (default:app-image)imageTag(optional) – Tag of the built image (default:latest)serviceName(optional) – Name of the deployed service (default:app-${projectId}). Set this to run multiple parallel deployments in the same project.volumes(optional) – Named volumes to mount into the deployed container, keyed by volume name with the container path as value, e.g.{ "app-data": "/var/lib/app/data" }
Returns:
{
deployedServiceId: string; // ID of deployed service
serviceName: string; // Service name
}Set up complete registry infrastructure (service + domain + registration).
Parameters:
apiClient– Mittwald API v2 client instanceprojectId– UUID of target projectstackId– ID of the existing stack hosting the registry service (required)projectShortId– Short project ID used for the registry hostnametimeout– Maximum time to wait for registry readiness
Returns:
{
username: string; // Registry username
password: string; // Registry password
uri: string; // Registry URL/hostname
host?: string; // Registry hostname/domain, when available
registryServiceId: string;
registry: any; // Registry API object
created?: boolean; // Whether newly created or already existed
}import { buildDockerImage, localDockerPush, checkDocker } from '@mittwald/container-deploy';
import type { RegistryData, RepositoryData } from '@mittwald/container-deploy';
// Check Docker availability
await checkDocker();
// Build image (auto-selects Railpack or Docker)
const builtRepositoryData = await buildDockerImage(registryData, repositoryData);
// Push to registry
await localDockerPush(builtRepositoryData, registryData);import {
// Registry
getProjectRegistry,
createRegistry,
checkProjectRegistry,
// Service
deployService,
// Domain
createDomain,
waitForDomainReachable,
createAndWaitForDomain,
Duration,
} from '@mittwald/container-deploy';
// Example: Create registry domain
const domain = await createAndWaitForDomain(
apiClient,
projectId,
'registry.example.com',
serviceId,
'80/tcp',
Duration.fromSeconds(180),
);Flexible duration handling for timeouts and scheduling:
import { Duration } from '@mittwald/container-deploy';
// Creation methods
const dur1 = Duration.fromSeconds(30);
const dur2 = Duration.fromMilliseconds(5000);
const dur3 = Duration.fromZero();
// Calculations
const combined = dur1.add(dur2);
const futureDate = dur1.fromNow();
const comparison = dur1.compare(dur2);
// Conversion
console.log(dur1.seconds); // 30
console.log(dur1.milliseconds); // 30000Each entity module handles exactly one domain:
- registry.ts = Mittwald Container Registry API only
- service.ts = Mittwald Service deployment API only
- domain.ts = Mittwald Domain/Ingress API only
- docker.ts = Local Docker operations only
This separation enables:
- Code reuse across different orchestration flows
- Independent testing of each domain
- Clear import dependencies (no circular imports)
- Easy extension with new orchestration workflows
Entity operations are stateless and composable. Orchestration modules (registry_setup.ts, deploy_project.ts) coordinate them:
User Request
↓
Orchestration Module ← Decides flow & manages state
↓
├→ Entity 1 ← Pure operation (Get → Transform → Return)
├→ Entity 2 ← Pure operation (Post → Wait → Return)
└→ Entity 3 ← Pure operation (Validate → Transform → Return)
↓
Result
This allows:
- New orchestration flows without modifying entities
- Testing orchestration logic independently of entities
- Different consumers composing entities differently (e.g., CLI might use registry setup orchestration, CI/CD might compose entities directly)
Operations that require polling use the internal waitUntil() helper:
// Internal pattern used by domain/service helpers
await waitUntil(
async () => isIngressReady(ingress) ? true : null,
Duration.fromSeconds(180),
);Critical known issue: Registry setup includes hardcoded 2-minute wait before API registration (documented Mittwald behavior for DNS/TLS propagation). This prevents race conditions when domain is created but not yet globally available.
All public functions are fully typed. Internal helper types are in src/types/index.ts:
export interface RegistryData {
username: string;
password: string;
uri: string;
host?: string; // Optional because checkProjectRegistry doesn't populate
registryServiceId: string;
registry: any;
created?: boolean;
}
export type DeployOptions = {
apiClient: MittwaldAPIV2Client;
projectId: string;
waitTimeout: Duration;
environment?: Record<string, string>;
imageName?: string; // Image name (default: "app-image")
imageTag?: string; // Image tag (default: "latest")
serviceName?: string;
volumes?: Record<string, string>;
} & (
| { stackId: string; registryStackId?: string; applicationStackId?: string }
| { stackId?: never; registryStackId: string; applicationStackId: string }
);The optional host field in RegistryData is intentional – different code paths populate different fields.
- Identify entities needed – Which entity modules are involved?
- Create new orchestration file –
src/orchestration/my_flow.ts - Compose entity imports – Import needed entity functions
- Define flow function – Export main function that coordinates steps
- Update exports – Add to
src/index.ts - Add tests – Mock entity modules in test suite
Example: If you need a custom orchestration around the standard app service:
// src/orchestration/registry_and_service_setup.ts
import { setupProjectRegistry } from './registry_setup';
import { deployService } from '../entities/service';
import { buildDockerImage, localDockerPush } from '../entities/docker';
import { checkRepository } from '../entities/repository';
export async function setupRegistryAndService(
apiClient: MittwaldAPIV2Client,
projectId: string,
stackId: string,
projectShortId: string,
timeout: Duration
): Promise<{ registry: RegistryData; serviceId: string }> {
const registry = await setupProjectRegistry(apiClient, projectId, stackId, projectShortId, timeout);
let repositoryData = await checkRepository();
repositoryData = await buildDockerImage(registry, repositoryData);
await localDockerPush(repositoryData, registry);
const { deployedServiceId } = await deployService(apiClient, projectId, stackId, repositoryData, timeout);
return { registry, serviceId: deployedServiceId };
}- Identify domain – Does it belong in existing module or new one?
- Add function – Export from entity module, keep pure (no multi-step logic)
- Type all parameters – Leverage MittwaldAPIV2Client types
- Document side effects – Comment on API calls, state changes, waiting
- Test independently – Mock API client in test suite
- Unit tests – Entity functions with mocked API client
- Integration tests – Orchestration functions with mocked entity functions
- End-to-end tests – Full flows against real API (in CI with proper credentials)
Current test suite validates primary deployProject() flow with mocked dependencies. See test/deploy.test.ts for patterns.
- Registry DNS Propagation – 2-minute hardcoded wait in
registry_setup.tsis necessary for Mittwald platform - Docker Availability – Operations in
docker.tsrequire local Docker installation - File:// Dependencies – When using in another package, ensure proper dependency management (Yarn 3.x tracks via content hash)
- Timeout Heuristics – Durations in orchestration are conservative estimates; adjust based on actual deployment patterns
This package follows TypeScript strict mode and Jest testing conventions. Before committing:
npm run build # Compile TypeScript
npm run test # Run test suite (jest)All new features should include:
- Type definitions in
src/types/index.tsif needed - Implementation in appropriate entity or orchestration module
- Tests in
test/ - Documentation in this README
src/
├── entities/ # Core domain entities
│ ├── docker.ts # Docker build configuration
│ ├── domain.ts # Domain and ingress handling
│ ├── project.ts # Project metadata
│ ├── registry.ts # Registry API operations
│ ├── repository.ts # Repository validation
│ └── service.ts # Service deployment logic
├── orchestration/ # High-level orchestration
│ ├── deploy_project.ts # Main deployment orchestrator
│ └── registry_setup.ts # Registry setup orchestrator
├── types/ # TypeScript type definitions
│ └── index.ts # Core types (DeployOptions, DeployResult, etc.)
└── utils/ # Utility functions
└── helpers.ts # Duration, password generation, etc.
test/
├── deploy.test.ts # Integration tests for deployProject
├── docker.test.ts # Docker build/push tests
├── repository.test.ts # Repository checks
└── service.test.ts # Service deployment tests
npm install
npm run build# One-time build
npm run build
# Watch mode for development
npm run build:watch# Run all tests
npm test
# Watch mode
npm test -- --watchTests are configured with Jest and ts-jest for TypeScript support. Current test coverage includes full integration tests for the deployProject function.
This package can be tested directly from git branches before publishing to npm:
"@mittwald/container-deploy": "github:mittwald/container-deploy#branch-name"For this to work, the dist folder must be committed after building. When released to npm, users receive the published version and don't need to worry about this.
{
apiClient: any; // Mittwald API client
projectId: string; // Project UUID
stackId?: string; // Shared/default stack ID
registryStackId?: string; // Registry stack ID; defaults to stackId
applicationStackId?: string; // Application stack ID; defaults to stackId
waitTimeout: Duration; // Deployment timeout
environment?: Record<string, string>; // Build/runtime environment
imageName?: string; // Image name (default: "app-image")
imageTag?: string; // Image tag (default: "latest")
serviceName?: string; // Service name (default: "app-${projectId}")
volumes?: Record<string, string>; // Named volume mounts: {"<volume-name>": "<mount-target>"}
}Provide stackId alone for a shared stack, or provide both service-specific
stack IDs. stackId may also be combined with one service-specific ID to
override the stack for only that service.
{
deployedServiceId: string; // ID of deployed service
serviceName: string; // Name of deployed service
}{
username: string;
password: string;
uri: string;
host?: string;
registryServiceId: string;
registry: any;
created?: boolean;
}{
buildContext: string;
ports: string[];
dockerfilePath?: string;
dockerfileContent?: string;
imageId?: string;
imageName?: string;
railpackPlanPath?: string | null;
}The library uses sensible defaults:
- Default password length: 32 characters
- Special characters in passwords: ~12.5% of password length
- Allowed special characters:
%,_,-,+,&
The deployment pipeline follows these steps:
- Project Lookup – Convert project UUID to short ID
- Environment Validation – Check Docker and Railpack availability
- Registry Setup – Create/configure container registry
- Repository Validation – Check local repository state
- Image Building – Build Docker image with buildkit
- Image Push – Push to configured registry
- Service Deployment – Deploy service to Mittwald infrastructure
MIT License.
Lars Bergmann l.bergmann@mittwald.de
For more information or issues, please visit the GitHub repository.