feat(templates): v2 — multi-service graph templates, expressions, compose import

Config v2 adds services[] to TemplateConfig: members declare key, type
(web|worker|cron|database), runtime, environment, volumes and depends_on.
Deploy topo-sorts the graph, creates databases first (managed DBs attached
to the project network under their key as DNS alias), then services with
resolved env, and enqueues a real deployment per service.

Expressions: {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}},
{{VAR:-default}} and {{service.<key>.<prop>}} with props host, port, url,
user, password, database. Credential-bearing refs (password/url) mark the
consuming variable secret — stored encrypted, masked in plan output.
Single-service template secret vars are now encrypted at rest too.

New endpoints: POST /templates/:id/plan (dry-resolve with missing/
unresolved reporting), POST /templates/import/compose (compose YAML →
v2 config; known db images become managed databases, build: flagged in
warnings), POST /templates/deploy (ad-hoc graph deploy — the compose
import companion).

Surfaces: CLI gains templates plan/import-compose/deploy-graph plus
--name/--var on deploy; 3 new MCP tools (88 total); OpenAPI + generated
frontend types; TemplatesPage shows stack members, deploys graphs without
a name field, renders the created resource list, and ships an
Import Compose modal feeding the template editor.

Seeded: WordPress + MySQL and n8n + PostgreSQL graph templates.

Partial-failure cleanup: resources created by a failed graph deploy are
removed (rows, containers, volumes).

Verified live: wordpress-stack deploy created a managed MySQL attached to
the project network as `db`; an inline pg+redis+nginx graph deployed with
DATABASE_URL=postgresql://app:<gen>@pg:5432/shop resolvable in-container.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 23:42:08 +02:00
1 parent 15045b8dd3
commit b4baa853b2
16 files changed
+2479 -48

No files matched your search

@@ -7,6 +7,7 @@ import {
deployTemplate,
getCurrentUserProfile,
getTemplateById,
importComposeTemplate,
listProjects,
listTemplates,
updateTemplate,
@@ -36,6 +37,8 @@ import {
Trash2,
Upload,
X,
FileCode,
Network,
} from 'lucide-react';
function toServiceName(value: string): string {
@@ -147,10 +150,16 @@ export function TemplatesPage() {
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
Record<string, Record<string, string>>
>({});
const [composeOpen, setComposeOpen] = useState(false);
const [composeYaml, setComposeYaml] = useState('');
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
const [composeError, setComposeError] = useState<string | null>(null);
const [lastDeployment, setLastDeployment] = useState<{
projectId: string;
serviceId: string;
serviceId?: string;
serviceName: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
} | null>(null);
const templatesQuery = useQuery({
@@ -281,6 +290,12 @@ export function TemplatesPage() {
});
}, [selectedDetail, variableValues]);
const graphMembers = useMemo(
() => selectedDetail?.config.services ?? [],
[selectedDetail],
);
const isGraphTemplate = graphMembers.length > 0;
const deployMutation = useMutation({
mutationFn: async () => {
if (!selectedTemplateId) {
@@ -296,17 +311,21 @@ export function TemplatesPage() {
return deployTemplate(selectedTemplateId, {
projectId: deployProjectId,
name: deployName.trim(),
name: isGraphTemplate ? undefined : deployName.trim(),
variables,
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['databases'] });
setLastDeployment({
projectId: deployProjectId,
serviceId: result.serviceId,
serviceName: deployName.trim(),
serviceName: isGraphTemplate
? `${result.created?.length ?? 0} resources`
: deployName.trim(),
created: result.created,
});
},
});
@@ -314,7 +333,7 @@ export function TemplatesPage() {
const isDeployDisabled =
!selectedTemplateId ||
!deployProjectId ||
deployName.trim().length === 0 ||
(!isGraphTemplate && deployName.trim().length === 0) ||
missingRequiredVariables.length > 0 ||
deployMutation.isPending;
@@ -344,6 +363,37 @@ export function TemplatesPage() {
},
});
const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeTemplate(yaml),
onSuccess: (result) => {
setComposeWarnings(result.warnings);
setComposeError(null);
setComposeOpen(false);
// Load the converted graph into the template editor for review/save.
setEditingTemplate(null);
setEditorIsPublic(false);
setEditorError(null);
setEditorJson(
JSON.stringify(
{
name: 'compose-stack',
description: 'Imported from docker-compose',
category: 'stack',
logo: '',
config: result.config,
variables: [],
},
null,
2,
),
);
setEditorOpen(true);
},
onError: (error) => {
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
},
});
const openCreateEditor = () => {
setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON);
@@ -438,13 +488,27 @@ export function TemplatesPage() {
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? (
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
<>
<button
type="button"
onClick={() => {
setComposeYaml('');
setComposeWarnings([]);
setComposeError(null);
setComposeOpen(true);
}}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<FileCode size={14} /> Import Compose
</button>
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
</>
) : null}
</div>
</div>
@@ -725,6 +789,42 @@ export function TemplatesPage() {
)}
</div>
{isGraphTemplate ? (
<div className="panel-soft p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Stack — {graphMembers.length} members
</p>
</div>
<div className="space-y-2">
{graphMembers.map((member) => (
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2 min-w-0">
{member.type === 'database' ? (
<Database size={13} className="text-[var(--info)] shrink-0" />
) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)}
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')}
</span>
) : null}
</div>
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span>
</div>
))}
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-3">
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
</p>
</div>
) : null}
{/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4">
@@ -733,7 +833,9 @@ export function TemplatesPage() {
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
<p className="text-sm text-[var(--text-secondary)]">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p>
</div>
</div>
@@ -767,6 +869,7 @@ export function TemplatesPage() {
))}
</select>
</div>
{!isGraphTemplate ? (
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service Name
@@ -784,6 +887,7 @@ export function TemplatesPage() {
placeholder="service-name"
/>
</div>
) : null}
</div>
{selectedDetail.variables.length > 0 && (
@@ -846,15 +950,30 @@ export function TemplatesPage() {
<div className="flex items-center gap-2">
<Check size={16} />
<span>
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
{lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span>
<Link
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
to={lastDeployment.created
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
>
View <ArrowRight size={12} />
</Link>
</div>
{lastDeployment.created ? (
<div className="mt-2 space-y-1">
{lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span>
</div>
))}
</div>
) : null}
</div>
)}
@@ -868,12 +987,12 @@ export function TemplatesPage() {
{deployMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating...
Deploying...
</>
) : (
<>
<Play size={16} />
Create Service
{isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
</>
)}
</button>
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
spellCheck={false}
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{composeWarnings.length > 0 ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
<p className="font-medium mb-1">Compose import notes</p>
{composeWarnings.map((w) => (
<p key={w}>• {w}</p>
))}
</div>
) : null}
{editorError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{editorError}
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
</div>
</div>
) : null}
{/* Compose import */}
{composeOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Paste a compose file — known database images become managed databases, everything else becomes services.
</p>
</div>
<button
type="button"
onClick={() => setComposeOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<X size={15} />
</button>
</div>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setComposeOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={!composeYaml.trim() || composeImportMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
+243 -1
View File
@@ -2801,6 +2801,10 @@ export interface paths {
* Create resource from template
* @description Create a new resource in a project using a selected template.
* For `database` templates, this creates a managed database service.
* For v2 graph templates (config.services non-empty), this deploys
* the entire service graph — databases first, then services in
* dependency order — and returns `resource: stack` with a `created`
* list. Deployments are enqueued for each service member.
*/
post: {
parameters: {
@@ -2835,6 +2839,187 @@ export interface paths {
patch?: never;
trace?: never;
};
"/templates/{id}/plan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Dry-resolve a template
* @description Resolves a template without creating resources: variable merging,
* `{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
* expansion and `{{service.<key>.<prop>}}` references. Generated
* secrets are returned masked — a real deploy regenerates them.
* Returns `members` (with resolved environment), `missing_variables`
* and `unresolved` reference names.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
project_id?: string;
variables?: {
[key: string]: string;
};
};
};
};
responses: {
/** @description Resolved plan */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
members?: components["schemas"]["TemplatePlanMember"][];
missing_variables?: string[];
unresolved?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/import/compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Convert docker-compose YAML to a template graph
* @description Parses a docker-compose YAML document and returns a v2 template
* config (`version: 2`, `services[]`). Well-known database images
* (postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
* become managed-database members; other images become web services.
* `build:` contexts are flagged in `warnings` — Containr deploys
* images or git repos, not local build contexts. The returned config
* can be saved via POST /templates or deployed directly via
* POST /templates/deploy.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Raw docker-compose YAML (max 512 KiB) */
compose_yaml: string;
};
};
};
responses: {
/** @description Converted config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
config?: components["schemas"]["TemplateConfig"];
variables?: Record<string, never>[];
warnings?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/deploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy an ad-hoc service graph
* @description Deploys a v2 graph config (config.services[]) without a stored
* template — the companion to POST /templates/import/compose.
* Members are created in dependency order; `database` members become
* managed databases attached to the project network under their key
* as DNS alias, and deployments are enqueued for service members.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
project_id: string;
config: components["schemas"]["TemplateConfig"];
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Graph deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: {
key?: string;
kind?: string;
id?: string;
name?: string;
deployment_id?: string;
}[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agent-tokens": {
parameters: {
query?: never;
@@ -9213,6 +9398,8 @@ export interface components {
is_public?: boolean;
};
TemplateConfig: {
/** @description Config version — 2 when services[] is used */
version?: number;
/** @description Service type for created service */
type?: string;
/** @description Runtime or base image */
@@ -9235,6 +9422,52 @@ export interface components {
nixpacks_config?: {
[key: string]: string;
};
/**
* @description v2 graph members. When non-empty, the flat fields above are
* ignored and deploy creates the whole graph in dependency order.
*/
services?: components["schemas"]["TemplateServiceSpec"][];
};
TemplateServiceSpec: {
/** @description Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs */
key: string;
/** @description Display/deployed name (defaults to key) */
name?: string;
/** @enum {string} */
type: "web" | "worker" | "cron" | "database";
/** @description Image reference (web/worker/cron) or managed engine (database) */
runtime?: string;
/** @description Git repository URL — when set, the member builds from source */
repo?: string;
branch?: string;
build_command?: string;
start_command?: string;
port?: number;
health_check?: string;
/** @enum {string} */
builder?: "auto" | "nixpacks" | "dockerfile" | "static";
/**
* @description Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
* {{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
* is host, port, url, user, password or database.
*/
environment?: {
[key: string]: string;
};
volumes?: components["schemas"]["ServiceVolume"][];
/** @description Member keys that must be created first */
depends_on?: string[];
};
TemplatePlanMember: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: {
[key: string]: string;
};
};
TemplateVariable: {
/** @description Variable key */
@@ -9277,11 +9510,20 @@ export interface components {
* @description Type of resource created by template deployment
* @enum {string}
*/
resource?: "service" | "database";
resource?: "service" | "database" | "stack";
/** @description Newly created service ID (set for non-database templates) */
service_id?: string;
/** @description Newly created managed database ID (set for database templates) */
database_id?: string;
/** @description Created resources (set for graph templates — resource=stack) */
created?: {
key?: string;
/** @enum {string} */
kind?: "service" | "database";
id?: string;
name?: string;
deployment_id?: string;
}[];
/** @description Deployment result message */
message?: string;
};
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+122 -8
View File
@@ -164,7 +164,20 @@ export type TemplateEntity = {
updatedAt?: string;
};
export type TemplateServiceSpecEntity = {
key: string;
name?: string;
type: string;
runtime?: string;
repo?: string;
branch?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplateConfigEntity = {
version?: number;
type: string;
runtime: string;
buildCommand: string;
@@ -174,6 +187,7 @@ export type TemplateConfigEntity = {
environment: Record<string, string>;
dockerfile?: string;
nixpacksConfig: Record<string, string>;
services?: TemplateServiceSpecEntity[];
};
export type TemplateVariableEntity = {
@@ -197,12 +211,15 @@ export type ListTemplatesInput = {
export type DeployTemplateInput = {
projectId: string;
name: string;
name?: string;
variables?: Record<string, string>;
};
export type DeployTemplateResult = {
serviceId: string;
resource: string;
serviceId?: string;
databaseId?: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
message: string;
};
@@ -696,7 +713,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
}
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
const services = Array.isArray(config?.services)
? config.services.map((s) => ({
key: s?.key ?? '',
name: s?.name ?? undefined,
type: s?.type ?? 'web',
runtime: s?.runtime ?? undefined,
repo: s?.repo ?? undefined,
branch: s?.branch ?? undefined,
port: s?.port ?? undefined,
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
environment: normalizeStringRecord(s?.environment),
}))
: undefined;
return {
version: config?.version ?? undefined,
type: config?.type ?? '',
runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '',
@@ -706,6 +737,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
services,
};
}
@@ -1586,21 +1618,103 @@ export async function deployTemplate(
method: 'POST',
body: JSON.stringify({
project_id: input.projectId,
name: input.name,
name: input.name ?? '',
variables: input.variables ?? {},
}),
});
if (!payload.service_id) {
throw new ApiError('Template deployment response is invalid', 500);
}
const created = Array.isArray(payload.created)
? payload.created
.filter((m) => m && typeof m.id === 'string')
.map((m) => ({
key: m.key ?? '',
kind: m.kind ?? '',
id: m.id ?? '',
name: m.name ?? '',
deploymentId: m.deployment_id ?? undefined,
}))
: undefined;
return {
serviceId: payload.service_id,
message: payload.message ?? 'Service created from template',
resource: payload.resource ?? 'service',
serviceId: payload.service_id ?? undefined,
databaseId: payload.database_id ?? undefined,
created,
message: payload.message ?? 'Template deployed',
};
}
export type TemplatePlanMember = {
key: string;
name: string;
type: string;
runtime?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplatePlanResult = {
members: TemplatePlanMember[];
missingVariables: string[];
unresolved: string[];
};
export async function planTemplate(
templateId: string,
input: { projectId?: string; variables?: Record<string, string> },
): Promise<TemplatePlanResult> {
const payload = await requestJson<{
members?: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: Record<string, string>;
}[];
missing_variables?: string[];
unresolved?: string[];
}>(`/templates/${templateId}/plan`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId ?? '',
variables: input.variables ?? {},
}),
});
return {
members: (payload.members ?? []).map((m) => ({
key: m.key ?? '',
name: m.name ?? '',
type: m.type ?? '',
runtime: m.runtime ?? undefined,
port: m.port ?? undefined,
dependsOn: m.depends_on ?? undefined,
environment: normalizeStringRecord(m.environment),
})),
missingVariables: payload.missing_variables ?? [],
unresolved: payload.unresolved ?? [],
};
}
export type ComposeImportResult = {
config: Record<string, unknown>;
warnings: string[];
};
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/compose',
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Compose import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':