Sponsor benefits
-
+
-
@@ -335,7 +332,7 @@ const faqs: FAQ[] = [
Become a Sponsor
@@ -372,47 +369,47 @@ const faqs: FAQ[] = [
-
+
-
Join the waitlist
-
- Get notified when ZaneOps Cloud launches and be among the first
- to try it
-
-
- Sign Up Now
-
-
-
-
-
-
-
+
Join the waitlist
+
+ Get notified when ZaneOps Cloud launches and be among the
+ first to try it
+
+
+ Sign Up Now
+
+
+
-
-
Become a sponsor
-
- Get early access, priority support, and help shape the future of
- ZaneOps
-
-
- Sponsor Now
-
-
-
+
+
+
+
+
+
Become a sponsor
+
+ Get early access, priority support, and help shape the
+ future of ZaneOps
+
+
+ Sponsor Now
+
+
+
diff --git a/src/components/templates/button.tsx b/src/components/templates/button.tsx
new file mode 100644
index 00000000..e114768a
--- /dev/null
+++ b/src/components/templates/button.tsx
@@ -0,0 +1,22 @@
+import { cn } from "~/lib/utils";
+
+export const buttonClassNames = [
+ "border border-border",
+ "bg-(--sl-color-bg-nav) dark:bg-bg hover:dark:bg-(--sl-color-grey-6)",
+ "focus:dark:bg-bg/80 focus:ring-(--sl-color-accent) focus:outline-none focus:ring-2",
+ "hover:border-(--sl-color-white) hover:bg-(--sl-color-gray-6)",
+ "transition-colors rounded-lg shadow-sm",
+ "inline-flex items-center justify-center",
+ "px-4 py-2"
+];
+
+export function Button({
+ className,
+ ...props
+}: React.ComponentProps<"button">) {
+ return
;
+}
+
+export function LinkButton({ className, ...props }: React.ComponentProps<"a">) {
+ return
;
+}
diff --git a/src/components/templates/input.tsx b/src/components/templates/input.tsx
new file mode 100644
index 00000000..a4b10b5d
--- /dev/null
+++ b/src/components/templates/input.tsx
@@ -0,0 +1,13 @@
+import { cn } from "~/lib/utils";
+
+export function Input({ className, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
diff --git a/src/components/templates/pagination.tsx b/src/components/templates/pagination.tsx
new file mode 100644
index 00000000..01dfc5d4
--- /dev/null
+++ b/src/components/templates/pagination.tsx
@@ -0,0 +1,78 @@
+import {
+ ChevronLeft,
+ ChevronRight,
+ ChevronsLeft,
+ ChevronsRight
+} from "lucide-react";
+import { Button } from "~/components/templates/button";
+import { cn } from "~/lib/utils";
+
+export type PaginationProps = {
+ totalPages: number;
+ currentPage: number;
+ className?: string;
+ onChangePage: (page: number) => void;
+};
+
+export function Pagination({
+ totalPages,
+ currentPage,
+ className,
+ onChangePage
+}: PaginationProps) {
+ return (
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/templates/template-search.tsx b/src/components/templates/template-search.tsx
new file mode 100644
index 00000000..f6aeef27
--- /dev/null
+++ b/src/components/templates/template-search.tsx
@@ -0,0 +1,377 @@
+import { TEMPLATE_API_HOST } from "astro:env/client";
+import {
+ keepPreviousData,
+ QueryClient,
+ QueryClientProvider,
+ useQuery
+} from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import {
+ ArrowRightIcon,
+ ArrowUpRightIcon,
+ CheckIcon,
+ ChevronRightIcon,
+ ChevronUpIcon,
+ LoaderIcon,
+ SearchIcon
+} from "lucide-react";
+import {
+ parseAsInteger,
+ parseAsNativeArrayOf,
+ parseAsString,
+ useQueryState
+} from "nuqs";
+import { NuqsAdapter } from "nuqs/adapters/react";
+import * as React from "react";
+import { Button } from "~/components/templates/button";
+import { Input } from "~/components/templates/input";
+import { Pagination } from "~/components/templates/pagination";
+import type { TemplateSearchAPIResponse } from "~/lib/types";
+import { cn, durationToMs } from "~/lib/utils";
+
+export default function TemplateSearchPage() {
+ const [queryClient] = React.useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ placeholderData: keepPreviousData,
+ gcTime: durationToMs(3, "days"),
+ retry(failureCount, error) {
+ // error responses are valid responses that react router can handle, so we don't want to retry them
+ return !(error instanceof Response) && failureCount < 3;
+ }
+ }
+ }
+ })
+ );
+
+ return (
+
+
+
+ {import.meta.env.DEV && }
+
+
+ );
+}
+
+const PER_PAGE = 18;
+
+export function TemplateSearch() {
+ const [searchTerm, setSearchTerm] = useQueryState("query");
+
+ const [currentPage, setCurrentPage] = useQueryState(
+ "page",
+ parseAsInteger.withDefault(1)
+ );
+ const [tags, setTags] = useQueryState(
+ "tags",
+ parseAsNativeArrayOf(parseAsString)
+ );
+
+ const templatesQuery = useQuery({
+ queryKey: ["TEMPLATES", { searchTerm, currentPage, tags }],
+ queryFn: async ({ signal }) => {
+ const url = new URL("/api/search", TEMPLATE_API_HOST);
+
+ if (searchTerm) {
+ url.searchParams.set("q", searchTerm);
+ }
+
+ url.searchParams.set("per_page", PER_PAGE.toString());
+ url.searchParams.set("page", currentPage.toString());
+ for (const tag of tags) {
+ url.searchParams.append("tags", tag);
+ }
+
+ const response = await fetch(url, { signal });
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch templates");
+ }
+
+ return response.json() as Promise
;
+ }
+ });
+
+ const hits = templatesQuery.data?.hits ?? [];
+
+ let totalPages = 0;
+
+ if (templatesQuery.data && templatesQuery.data.found > hits.length) {
+ totalPages = Math.ceil(templatesQuery.data.found / PER_PAGE);
+ }
+
+ return (
+
+ );
+}
+
+type TagsListFormProps = {
+ selectedTags: string[];
+ onTagSelectChange: (newValues: string[]) => void;
+};
+
+function TagsListForm({ selectedTags, onTagSelectChange }: TagsListFormProps) {
+ const { data: tags = [] } = useQuery({
+ queryKey: ["TAGS"],
+ queryFn: async ({ signal }) => {
+ const url = new URL("/api/tags.json", TEMPLATE_API_HOST);
+ const response = await fetch(url, { signal });
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch tags");
+ }
+
+ return response.json() as Promise;
+ }
+ });
+
+ const [showAll, setShowAll] = React.useState(false);
+
+ const [tagSearch, setTagSearch] = React.useState("");
+
+ const tagList = React.useMemo(() => {
+ let filteredTags = tags.toSorted((tagA, tagB) => {
+ // put selected tags first & sort alphabetically
+ if (selectedTags.includes(tagA) && selectedTags.includes(tagB)) {
+ return tagA > tagB ? 1 : -1;
+ }
+ if (selectedTags.includes(tagA)) {
+ return -1;
+ }
+ if (selectedTags.includes(tagB)) {
+ return 1;
+ }
+ return 0;
+ });
+
+ if (showAll || tagSearch.trim()) {
+ filteredTags = filteredTags.filter((tag) => tag.includes(tagSearch));
+ } else {
+ filteredTags = filteredTags.slice(0, 10);
+ }
+
+ return filteredTags;
+ }, [showAll, tagSearch, selectedTags, tags]);
+
+ return (
+
+ );
+}
+
+export function TemplateCard({
+ id,
+ name,
+ description,
+ logoUrl: logo
+}: {
+ id: string;
+ name: string;
+ description: string;
+ logoUrl: string;
+}) {
+ const logoUrl = new URL(logo, TEMPLATE_API_HOST);
+ return (
+
+ );
+}
diff --git a/src/content/docs/changelog/v1.10.mdx b/src/content/docs/changelog/v1.10.mdx
index 8ade4e4c..fdd68c84 100644
--- a/src/content/docs/changelog/v1.10.mdx
+++ b/src/content/docs/changelog/v1.10.mdx
@@ -4,6 +4,8 @@ description: 'Sh...🤫 ells !'
---
import {Aside} from '@astrojs/starlight/components';
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.10.0
+
25 May 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
Today we release ZaneOps v1.10, introducing a web terminal for deployments and a web terminal to the server.
diff --git a/src/content/docs/changelog/v1.11.mdx b/src/content/docs/changelog/v1.11.mdx
index b81d9b90..d7f86ab6 100644
--- a/src/content/docs/changelog/v1.11.mdx
+++ b/src/content/docs/changelog/v1.11.mdx
@@ -6,6 +6,9 @@ description: 'Private repos, auto-deploy on Git push'
import {Aside} from '@astrojs/starlight/components';
import { ASSETS_SERVER_DOMAIN } from "astro:env/client"
+
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.11.0
+
22 July 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
diff --git a/src/content/docs/changelog/v1.12.mdx b/src/content/docs/changelog/v1.12.mdx
index 27d1413a..bd7c648e 100644
--- a/src/content/docs/changelog/v1.12.mdx
+++ b/src/content/docs/changelog/v1.12.mdx
@@ -6,6 +6,7 @@ description: Preview environments, new design, account settings and more
import {Aside} from '@astrojs/starlight/components';
import { ASSETS_SERVER_DOMAIN } from "astro:env/client"
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.12
7 October 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
diff --git a/src/content/docs/changelog/v1.13.mdx b/src/content/docs/changelog/v1.13.mdx
new file mode 100644
index 00000000..daa9f375
--- /dev/null
+++ b/src/content/docs/changelog/v1.13.mdx
@@ -0,0 +1,106 @@
+---
+title: ZaneOps v1.13
+description: Docker compose, Cloud waitlist, templates and more
+---
+
+import {Aside} from '@astrojs/starlight/components';
+import { ASSETS_SERVER_DOMAIN } from "astro:env/client"
+
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.13.0
+
+28 February 2026 by [**Fred KISSIE**](https://github.com/Fredkiss3)
+
+
+ZaneOps v1.13 is here with some big additions: [Docker Compose support](/knowledge-base/docker-compose), [templates](/knowledge-base/docker-compose/01-templates), shared volumes, build registries, and more.
+
+
+**To install:**
+
+```shell
+# assuming you are at /var/www/zaneops
+curl https://cdn.zaneops.dev/makefile > Makefile
+make setup
+make deploy
+```
+
+
+
+### ZaneOps Cloud ☁️ waitlist
+
+The waitlist for [ZaneOps Cloud](/cloud) is now open. ZaneOps Cloud is the next evolution of the platform, designed for teams and enterprise users. It will ship with:
+
+- **Multi-tenancy**: support for multiple teams with fine-grained permissions
+- **OIDC Authentication**: enterprise-grade SSO via OpenID Connect
+- **Audit logs**: full activity trail of logins, deployments, service changes, and more
+- **Custom authentication pages**: protect any service behind a login page
+- **Custom branding**: white-label your authentication pages
+- ...and more
+
+[Join the waitlist](/cloud) to be notified when the cloud version launches. [Sponsor the project](https://github.com/sponsors/zane-ops) to get **early access** when it does.
+
+
+
+
+### Docker Compose stacks
+
+The headline feature of v1.13 is native [Docker Compose stack support](/knowledge-base/docker-compose). You can now deploy any `docker-compose.yml` file directly on ZaneOps.
+
+Under the hood, compose files are deployed as [Docker stacks](https://docs.docker.com/reference/cli/docker/stack/), with:
+
+- blue/green deployments out of the box
+- domain assignment via service labels
+- dynamic value generation via template expressions (e.g. `{{ generate_password | 8 }}` generates a secure 8-character password)
+
+
+
+
+### Templates
+
+Beyond raw compose files, you can also deploy from pre-built [templates](/knowledge-base/docker-compose/01-templates). ZaneOps supports two template providers:
+
+- Our own curated templates:
+
+
+- Dokploy templates (experimental):
+
+
+
+### Shared volumes
+
+You can now create volumes shared across multiple services. One service owns the volume, and others mount it as read-only. This is useful for sharing assets, media, or uploads between a backend and frontend, for example.
+
+Shared volumes are fully compatible with blue/green deployments.
+
+
+
+
+### Docker registry credentials
+
+Deploying private images no longer requires entering credentials on every service. You can now create registry credentials once and reuse them across all your apps.
+
+
+
+
+### Build registries
+
+In preparation for multi-server support, you can now configure a build registry to store Docker images built by ZaneOps. Choose between storing images on your server's filesystem or pushing them to an S3-compatible provider (AWS S3, MinIO, R2, etc.).
+
+
+
+
+
+
+### Other changes
+
+- Environment variables are now obfuscated in build logs and hidden by default in change fields
+ 
+- Git services now receive `GIT_COMMIT_SHA` as both a build arg and an environment variable
+ 
+- After creating an SSH key in server settings, ZaneOps now shows the exact commands to add it to your server
+ 
+- ZaneOps remembers the last SSH key used in the server terminal and automatically reconnects you on your next visit
+
diff --git a/src/content/docs/changelog/v1.7.mdx b/src/content/docs/changelog/v1.7.mdx
index 064c9db7..b0ec3527 100644
--- a/src/content/docs/changelog/v1.7.mdx
+++ b/src/content/docs/changelog/v1.7.mdx
@@ -5,6 +5,9 @@ description: 'See all the new and interesting changes in ZaneOps v1.7'
import {Aside} from '@astrojs/starlight/components';
+
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.7.0
+
22 Mar 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
diff --git a/src/content/docs/changelog/v1.8.mdx b/src/content/docs/changelog/v1.8.mdx
index bd7d6771..09eaf992 100644
--- a/src/content/docs/changelog/v1.8.mdx
+++ b/src/content/docs/changelog/v1.8.mdx
@@ -5,6 +5,7 @@ description: 'We support git repositories now'
import {Aside} from '@astrojs/starlight/components';
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.8.0
31 Mar 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
diff --git a/src/content/docs/changelog/v1.9.mdx b/src/content/docs/changelog/v1.9.mdx
index d0257da5..2973805c 100644
--- a/src/content/docs/changelog/v1.9.mdx
+++ b/src/content/docs/changelog/v1.9.mdx
@@ -5,6 +5,8 @@ description: '1, 2, 3 new builders!'
import {Aside} from '@astrojs/starlight/components';
+> Release notes: https://github.com/zane-ops/zane-ops/releases/tag/v1.9.0
+
20 Apr 2025 by [**Fred KISSIE**](https://github.com/Fredkiss3)
Today we release zaneOps v1.9 introducing not 1, not 2, but [3 new builders](/knowledge-base/builders/nixpacks-builder) for your git services!
diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx
index 72658784..26e1753a 100644
--- a/src/content/docs/index.mdx
+++ b/src/content/docs/index.mdx
@@ -4,7 +4,7 @@ description: ZaneOps is a self-hosted, open source platform as a service for hos
template: splash
banner:
content: |
- Signup to ZaneOps Cloud waitlist
+ New in v1.13: docker-compose, templates, ZaneOps Cloud
hero:
title: ''
---
diff --git a/src/content/docs/knowledge-base/Docker compose/01-templates.mdx b/src/content/docs/knowledge-base/Docker compose/01-templates.mdx
new file mode 100644
index 00000000..5a6df3ea
--- /dev/null
+++ b/src/content/docs/knowledge-base/Docker compose/01-templates.mdx
@@ -0,0 +1,571 @@
+---
+title: Templates
+description: Learn how to deploy templates on ZaneOps
+---
+
+import {Steps, Aside} from '@astrojs/starlight/components';
+
+
+> Introduced in [**v1.13.0**](/changelog/v113)
+
+ZaneOps allows you to deploy [Docker Compose Stacks](../) from preexisting templates.
+
+There are two kinds:
+- Curated ZaneOps templates (Postgres, Redis, WordPress, and more)
+- Dokploy templates (experimental)
+
+
+
+
+## ZaneOps templates
+
+ZaneOps maintains a curated library of ready-to-deploy templates including Postgres, Redis, WordPress, Plausible, etc. **[Browse all templates ↗](/templates)**
+
+### How to deploy a ZaneOps template
+
+
+1. In the ZaneOps dashboard go to your project and select **New > Compose Stack**
+ 
+2. Select **from ZaneOps template**
+ 
+3. Search and select your template
+ 
+4. Review or modify the compose file, then click **Create** and **Deploy**
+ 
+5. Once deployed, explore your running services and follow any onboarding steps specific to the template
+ 
+ 
+
+
+## Dokploy templates (experimental)
+
+ZaneOps includes an adapter to import templates from Dokploy.
+[Dokploy templates](https://templates.dokploy.com) are base64-encoded JSON containing a compose YAML and a TOML config.
+
+
+
+### How to deploy a dokploy template
+
+
+1. In the ZaneOps dashboard go to your project and select **New > Compose Stack**
+ 
+2. Select **from Dokploy template**
+ 
+3. Copy and paste your template:
+
+ You can either copy the encoded base64 configuration
+ 
+ 
+ Or copy the individual compose file and config
+ 
+ 
+4. Click **Create** and **Deploy**
+5. Once deployed, explore your running services and follow any onboarding steps specific to the template
+ 
+ 
+
+
+
+### Dokploy Template Migration
+
+ZaneOps includes an adapter to import templates from Dokploy. If you have existing Dokploy templates, here's how to migrate them.
+
+#### Dokploy Template Format
+
+Dokploy templates are base64-encoded JSON containing:
+- **compose**: Docker Compose YAML with placeholders
+- **config**: TOML with variables, domains, env, and file mounts
+
+Example Dokploy template structure (base64):
+
+```
+eyJjb21wb3NlIjogInNlcnZpY2VzOlxuICB3ZWI6XG4gICAgaW1hZ2U6IG5naW54XG4gICAgZW52aXJvbm1lbnQ6XG4gICAgICBQQVNTV09SRDogJHtQQVNTV09SRH1cbiIsICJjb25maWciOiAiW3ZhcmlhYmxlc11cbnBhc3N3b3JkID0gXCIke3Bhc3N3b3JkOjMyfVwiXG5cbltjb25maWcuZW52XVxuUEFTU1dPUkQ9JHtwYXNzd29yZH1cblxuW1tjb25maWcuZG9tYWluc11dXG5zZXJ2aWNlTmFtZSA9IFwid2ViXCJcbmhvc3QgPSBcImV4YW1wbGUuY29tXCJcbnBvcnQgPSA4MFxuIn0=
+```
+
+Which is the base64 encoding of this JSON:
+
+```json
+{
+ "compose": "services:\n web:\n image: nginx\n environment:\n PASSWORD: ${PASSWORD}\n",
+ "config": "[variables]\npassword = \"${password:32}\"\n\n[config.env]\nPASSWORD=${password}\n\n[[config.domains]]\nserviceName = \"web\"\nhost = \"example.com\"\nport = 80\n"
+}
+```
+
+Which decodes to:
+
+```yaml title="compose.yaml"
+services:
+ web:
+ image: nginx
+ environment:
+ PASSWORD: ${PASSWORD}
+```
+
+```toml title="template.toml"
+[variables]
+password = "${password:32}"
+
+[config.env]
+PASSWORD=${password}
+
+[[config.domains]]
+serviceName = "web"
+host = "example.com"
+port = 80
+```
+
+---
+
+#### Placeholder Mapping
+
+Dokploy placeholders are automatically converted to ZaneOps template expressions:
+
+| Dokploy Placeholder | ZaneOps Expression |
+| --- | --- |
+| `${domain}` | `{{ generate_domain }}` |
+| `${email}` | `{{ generate_email }}` |
+| `${username}` | `{{ generate_username }}` |
+| `${uuid}` | `{{ generate_uuid }}` |
+| `${password}`, `${hash}`, `${jwt}` | `{{ generate_password \| 32 }}` |
+| `${password:N}`, `${hash:N}`, `${jwt:N}` | `{{ generate_password \| N }}` |
+| `${base64}` | `{{ generate_base64 \| 32 }}` |
+| `${base64:N}` | `{{ generate_base64 \| N }}` |
+
+---
+
+#### Conversion Process
+
+
+
+
+1. **Decode**: the base64 JSON is decoded into a compose YAML and a TOML config (or used as-is if provided separately)
+2. **Placeholder substitution**: Dokploy placeholders (e.g. `${domain}`, `${password:32}`) are replaced with their equivalent ZaneOps template expressions (see [Placeholder mapping](#placeholder-mapping) below)
+3. **Process variables**: Extract `[variables]` and `[[config.env]]` section into `x-zane-env`
+3. **Domain conversion**: each `[[config.domains]]` entry in the TOML config is converted to ZaneOps routing labels on the matching service
+4. **Mount conversion**: each `[[config.mounts]]` entry is converted to an inline Docker config. If no matching mount is found for a `../files/` volume path, the path is converted to a named volume instead
+6. **Clean up**: Remove `ports`, `expose`, `restart`
+5. **Standard processing**: the resulting compose file goes through the same pipeline as any other ZaneOps compose file: template expressions are evaluated, service names are hashed, networks are injected, and the stack is deployed
+
+
+
+---
+
+#### Example Migration
+
+**Dokploy template content**:
+```yaml
+# compose.yaml
+services:
+ web:
+ image: nginx:alpine
+ ports:
+ - "8080:80"
+ environment:
+ DB_PASSWORD: ${DB_PASSWORD}
+ ADMIN_EMAIL: ${ADMIN_EMAIL}
+ volumes:
+ - ../files/nginx.conf:/etc/nginx/nginx.conf
+```
+
+```toml
+# template.toml
+[variables]
+main_domain = "${domain}"
+db_password = "${password:32}"
+admin_email = "${email}"
+
+[[config.domains]]
+serviceName = "web"
+host = "${main_domain}"
+port = 8080
+
+[[config.env]]
+DB_PASSWORD = "${db_password}"
+ADMIN_EMAIL = "${admin_email}"
+
+[[config.mounts]]
+filePath = "nginx.conf"
+content = """
+server {
+ listen 80;
+}
+"""
+```
+
+**Resulting ZaneOps compose.yaml**:
+```yaml
+# docker-compose.yml
+x-zane-env:
+ main_domain: "{{ generate_domain }}"
+ db_password: "{{ generate_password | 32 }}"
+ admin_email: "{{ generate_email }}"
+ DB_PASSWORD: ${db_password}
+ ADMIN_EMAIL: ${admin_email}
+
+services:
+ web:
+ image: nginx:alpine
+ environment:
+ DB_PASSWORD: ${DB_PASSWORD}
+ ADMIN_EMAIL: ${ADMIN_EMAIL}
+ configs:
+ - source: nginx.conf
+ target: /etc/nginx/nginx.conf
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "${main_domain}"
+ zane.http.routes.0.port: "8080"
+ zane.http.routes.0.base_path: "/"
+ zane.http.routes.0.strip_prefix: "false"
+
+configs:
+ nginx.conf:
+ content: |
+ server {
+ listen 80;
+ }
+```
+
+---
+
+#### Mount Processing
+
+Dokploy uses `../files/` prefix for file mounts. The adapter converts these to Docker configs.
+
+**Case 1: Directory mount**
+
+**Dokploy:**
+```yaml
+# compose.yml
+# ... rest of the file
+volumes:
+ - ../files/clickhouse_config:/etc/clickhouse-server/config.d
+
+```
+
+```toml
+# template.toml
+[[config.mounts]]
+filePath = "clickhouse_config/logging_rules.xml"
+content = "..."
+
+[[config.mounts]]
+filePath = "clickhouse_config/network.xml"
+content = "..."
+```
+
+**ZaneOps result:**
+```yaml
+# ... rest of the file
+# docker-compose.yml
+configs:
+ - source: logging_rules.xml
+ target: /etc/clickhouse-server/config.d/logging_rules.xml
+ - source: network.xml
+ target: /etc/clickhouse-server/config.d/network.xml
+
+configs:
+ logging_rules.xml:
+ content: "..."
+ network.xml:
+ content: "..."
+```
+
+---
+
+**Case 2: File mount**
+
+**Dokploy:**
+```yaml
+# ... rest of the file
+# compose.yml
+volumes:
+ - ../files/nginx.conf:/etc/nginx/nginx.conf:ro
+```
+
+```toml
+# template.toml
+[[config.mounts]]
+filePath = "nginx.conf"
+content = "..."
+```
+
+**ZaneOps result:**
+```yaml
+# ... rest of the file
+# docker-compose.yml
+configs:
+ - source: nginx.conf
+ target: /etc/nginx/nginx.conf
+
+configs:
+ nginx.conf:
+ content: "..."
+```
+
+---
+
+**Case 3: Non-existent path (becomes volume)**
+
+**Dokploy:**
+```yaml
+# ... rest of the file
+# compose.yml
+volumes:
+ - ../files/data:/app/data
+```
+
+If no matching mount exists → converted to named volume:
+```yaml
+# ... rest of the file
+# docker-compose.yml
+volumes:
+ - data:/app/data
+
+volumes:
+ data:
+```
+
+#### Ports processing
+
+`ports` entries are removed and replaced with ZaneOps routing labels derived from the `[[config.domains]]` entries in the TOML config.
+
+**Dokploy:**
+```yaml
+# compose.yml
+services:
+ web:
+ image: nginx:alpine
+ ports:
+ - "8080:80"
+```
+
+```toml
+# template.toml
+[variables]
+main_domain = ${domain}
+
+[[config.domains]]
+serviceName = "web"
+host = "${main_domain}"
+port = 8080
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+x-zane-env:
+ main_domain: '{{ generate_domain }}'
+
+services:
+ web:
+ image: nginx:alpine
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "${main_domain}"
+ zane.http.routes.0.port: "8080"
+```
+
+#### `depends_on`
+
+The dict form of `depends_on` is converted to a plain list, since Docker Swarm only supports the list form. All `condition` values are dropped.
+
+**Dokploy:**
+```yaml
+# compose.yml
+depends_on:
+ rybbit_clickhouse:
+ condition: service_healthy
+ rybbit_postgres:
+ condition: service_started
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+depends_on:
+ - rybbit_clickhouse
+ - rybbit_postgres
+```
+
+---
+
+#### Self-referencing variables in `config.env`
+
+If a `config.env` entry references itself (e.g. `KEY = "${KEY}"`), the adapter ignores it and keeps the original template expression from `[variables]` instead. Cross-references and new values are kept as-is.
+
+**Dokploy:**
+```toml
+# template.toml
+[variables]
+KENER_SECRET_KEY = "${password:64}"
+DB_PASSWORD = "${password:32}"
+MYSQL_PASSWORD = "${password:32}"
+
+[config.env]
+KENER_SECRET_KEY = "${KENER_SECRET_KEY}" # self-reference — ignored
+MYSQL_PASSWORD = "${MYSQL_PASSWORD}" # self-reference — ignored
+POSTGRES_PASSWORD = "${DB_PASSWORD}" # cross-reference — kept
+TZ = "Etc/UTC" # new value — added
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+x-zane-env:
+ KENER_SECRET_KEY: "{{ generate_password | 64 }}"
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+ MYSQL_PASSWORD: "{{ generate_password | 32 }}"
+ POSTGRES_PASSWORD: "${DB_PASSWORD}"
+ TZ: "Etc/UTC"
+```
+
+---
+
+#### Static variables in `config.variables`
+
+Variables without Dokploy placeholders in `[variables]` are kept as literal values.
+
+**Dokploy:**
+```toml
+# template.toml
+[variables]
+pg_user = "authentik"
+pg_db = "authentik"
+
+[config.env]
+PG_USER = "${pg_user}"
+PG_DB = "${pg_db}"
+PG_PASS = "${password:32}"
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+x-zane-env:
+ pg_user: "authentik"
+ pg_db: "authentik"
+ PG_USER: "${pg_user}"
+ PG_DB: "${pg_db}"
+ PG_PASS: "{{ generate_password | 32 }}"
+```
+
+---
+
+#### Empty environment variable entries
+
+Environment entries without a value (list-style passthrough) are replaced with `${KEY}` if that key exists in `x-zane-env`. Entries with no matching key are dropped.
+
+**Dokploy:**
+```yaml
+# compose.yml
+services:
+ docmost:
+ environment:
+ - APP_URL # resolved from config.env
+ - APP_SECRET # resolved from config.env
+ - APP_WHATEVER # not in config.env — dropped
+ - DATABASE_URL=postgresql://... # has value — kept as-is
+```
+
+```toml
+# template.toml
+[config.env]
+APP_URL = "http://${main_domain}:3000"
+APP_SECRET = "${app_secret}"
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+x-zane-env:
+ APP_URL: "http://${main_domain}:3000"
+ APP_SECRET: "${app_secret}"
+
+services:
+ docmost:
+ environment:
+ APP_URL: "${APP_URL}"
+ APP_SECRET: "${APP_SECRET}"
+ DATABASE_URL: "postgresql://..."
+```
+
+---
+
+#### Variable references without curly braces
+
+`$VAR` (no braces) is normalized to `${VAR}`. `$$VAR` (shell escape) is preserved unchanged.
+
+**Dokploy:**
+```yaml
+# compose.yml
+services:
+ wordpress:
+ environment:
+ WORDPRESS_DB_NAME: $DB_NAME
+ WORDPRESS_DEBUG: ${WORDPRESS_DEBUG:-0}
+ wp_db:
+ healthcheck:
+ test: ["CMD-SHELL", "exit | mysql -u root -p$$MYSQL_ROOT_PASSWORD"]
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+services:
+ wordpress:
+ environment:
+ WORDPRESS_DB_NAME: "${DB_NAME}"
+ WORDPRESS_DEBUG: "${WORDPRESS_DEBUG:-0}"
+ wp_db:
+ healthcheck:
+ test: ["CMD-SHELL", "exit | mysql -u root -p$$MYSQL_ROOT_PASSWORD"]
+```
+
+---
+
+#### `env_file` replacement
+
+`env_file` references are removed and replaced with inline `environment` entries from `config.env`.
+
+**Dokploy:**
+```yaml
+# compose.yml
+services:
+ plausible:
+ image: ghcr.io/plausible/community-edition:v2.1.5
+ env_file:
+ - .env
+```
+
+```toml
+# template.toml
+[config.env]
+BASE_URL = "http://${main_domain}"
+SECRET_KEY_BASE = "${secret_base}"
+TOTP_VAULT_KEY = "${totp_key}"
+```
+
+**ZaneOps result:**
+```yaml
+# docker-compose.yml
+x-zane-env:
+ main_domain: "{{ generate_domain }}"
+ secret_base: "{{ generate_password | 64 }}"
+ totp_key: "{{ generate_password | 32 }}"
+
+services:
+ plausible:
+ image: ghcr.io/plausible/community-edition:v2.1.5
+ environment:
+ BASE_URL: "http://${main_domain}"
+ SECRET_KEY_BASE: "${secret_base}"
+ TOTP_VAULT_KEY: "${totp_key}"
+```
diff --git a/src/content/docs/knowledge-base/Docker compose/02-compose-file-reference.mdx b/src/content/docs/knowledge-base/Docker compose/02-compose-file-reference.mdx
new file mode 100644
index 00000000..b4ff3ff6
--- /dev/null
+++ b/src/content/docs/knowledge-base/Docker compose/02-compose-file-reference.mdx
@@ -0,0 +1,650 @@
+---
+title: Compose file syntax reference
+description: Learn how to create a compose stack file on ZaneOps
+---
+
+import {Aside, Steps} from '@astrojs/starlight/components'
+
+> Introduced in [**v1.13.0**](/changelog/v113)
+
+ZaneOps accepts standard Docker Compose files with a few extensions and restrictions described below.
+
+## File structure
+
+Any valid compose file with at least one `services` entry works. The ZaneOps-specific additions are all optional except when you need routing or generated secrets.
+
+```yaml title="docker-compose.yml" title="docker-compose.yml"
+# ZaneOps-specific: template expressions for generating values at deploy time
+x-zane-env:
+ VAR_NAME: "{{ template_expression }}"
+
+# Standard services
+services:
+ service_name:
+ image: image:tag
+ environment:
+ KEY: ${VALUE} # references an x-zane-env variable
+ deploy:
+ labels:
+ # ZaneOps-specific: label-based routing instead of ports
+ zane.http.routes.0.domain: "example.com"
+ zane.http.routes.0.port: "8080"
+
+# Standard named volumes
+volumes:
+ data:
+
+# Inline config files (standard content key, ZaneOps adds ${VAR} interpolation and auto-versioning)
+configs:
+ my_config:
+ content: |
+ server { listen 80; }
+```
+
+## How ZaneOps processes your file
+
+Before deploying, ZaneOps transforms the compose file through these steps:
+
+
+1. **Template processing**: `x-zane-env` expressions evaluated, `${VAR}` references expanded
+2. **Service name hashing**: all service names prefixed with the stack's hash (e.g. `app` → `abc123_app`) to prevent DNS collisions across stacks
+3. **Network injection**: three networks attached to every service automatically (see [Networks](#networks))
+4. **Config versioning**: inline configs created as versioned Docker configs (`my_config_v1`, `my_config_v2`, ...)
+5. **Computed file**: the fully processed compose file (with all variables resolved, service names hashed, and networks injected) is saved and visible in the ZaneOps UI so you can inspect exactly what gets deployed
+ 
+ 
+6. **Stack deployment**: `docker stack deploy --with-registry-auth` is executed using the computed compose file
+ 
+
+
+
+---
+
+## `x-zane-env`
+
+`x-zane-env` is a ZaneOps-specific top-level key that replaces the role of a `.env` file. It lets you define stack-wide variables (either plain values or template expressions) that are then referenced throughout the rest of the compose file.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ # Plain value (behaves like a .env file entry)
+ APP_PORT: "3000"
+
+ # Template expression (generates a value at deploy time)
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+```
+
+All variables are referenced with **`${VAR_NAME}`** syntax (curly braces required). Variables referenced without curly braces (`$VAR_NAME`) are left as-is and not expanded. Referencing an undefined variable with `${VAR_NAME}` expands to an empty string.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+
+services:
+ app:
+ environment:
+ PASSWORD: ${DB_PASSWORD} # ✅ expanded to the generated value
+ RAW: $DB_PASSWORD # ❌ not expanded, kept literally as "$DB_PASSWORD"
+ MISSING: ${UNDEFINED_VAR} # ⚠️ expanded to an empty string
+```
+
+
+
+
+- Values from template expressions are evaluated **once on the first deployment** and then persisted. A generated password won't change on subsequent deploys.
+ 
+- The variables are persisted as environment overrides:
+ 
+
+
+### Available template functions
+
+#### `generate_username`
+
+Generates a random username in the format `{adjective}{animal}{number}`.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_USER: "{{ generate_username }}"
+# Output example: reddog65, bluecat42
+```
+
+---
+
+#### `generate_password | `
+
+Generates a cryptographically secure random password as a hexadecimal string. Length must be an even number ≥ 8.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+ API_SECRET: "{{ generate_password | 64 }}"
+```
+
+```yaml title="docker-compose.yml"
+# ❌ Wrong: odd length
+PASSWORD: "{{ generate_password | 31 }}"
+
+# ✅ Correct
+PASSWORD: "{{ generate_password | 32 }}"
+```
+
+---
+
+#### `generate_base64 | `
+
+Generates a base64-encoded random string of N bytes. Minimum value is 8.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ ENCRYPTION_KEY: "{{ generate_base64 | 32 }}"
+ SESSION_SECRET: "{{ generate_base64 | 64 }}"
+```
+
+---
+
+#### `generate_slug`
+
+Generates a URL-friendly slug in the format `{adjective}-{noun}-{number}`.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_NAME: "{{ generate_slug }}"
+# Output example: happy-tree-91
+```
+
+---
+
+#### `generate_domain`
+
+Generates a unique domain scoped to your stack: `{project_slug}-{stack_slug}-{random}.{ROOT_DOMAIN}`.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ APP_URL: "{{ generate_domain }}"
+ CALLBACK_URL: "https://{{ generate_domain }}/auth/callback"
+# Output example: my-app-backend-a1b2c3.zaneops.dev
+```
+
+---
+
+#### `generate_uuid`
+
+Generates a UUID v4.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ INSTALLATION_ID: "{{ generate_uuid }}"
+```
+
+---
+
+#### `generate_email`
+
+Generates a fake but valid-looking email address.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ ADMIN_EMAIL: "{{ generate_email }}"
+```
+
+---
+
+#### `network_alias | 'service_name'`
+
+Generates a stable, environment-scoped hostname. Use this when you need to reference a service in this stack from another service or stack in the same environment.
+
+**Format**: `{network_alias_prefix}-{service_name}`
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_HOST: "{{ network_alias | 'postgres' }}"
+ REDIS_URL: "redis://{{ network_alias | 'redis' }}:6379"
+# Output example: my-stack-postgres
+```
+
+The alias is stable across redeployments and scoped to the environment, so services in the same environment share the same alias space.
+
+---
+
+#### `global_alias | 'service_name'`
+
+Generates a globally unique hostname, accessible across all projects and environments.
+
+**Format**: `{hash_prefix}_{service_name}`
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ GLOBAL_DB: "{{ global_alias | 'postgres' }}"
+# Output example: abc123_postgres
+```
+
+Use this only for cross-project or cross-environment communication. Use `network_alias` to connect in the same environment
+and use the service name directly to reference it in the stack.
+
+---
+
+### Variable composition
+
+Variables in `x-zane-env` can reference each other using `${VAR}` syntax to build composite values:
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_USER: "{{ generate_username }}"
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+ DB_NAME: "{{ generate_slug }}"
+ DB_HOST: "{{ network_alias | 'postgres' }}"
+
+ DATABASE_URL: "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}"
+
+services:
+ app:
+ image: myapp:latest
+ environment:
+ DATABASE_URL: ${DATABASE_URL}
+```
+
+### Exposed variables (`__` prefix)
+
+Variables whose name starts with `__` are surfaced as **environment overrides** in the ZaneOps UI. This makes their resolved value visible and copyable, so it can easily be used by services outside the stack.
+
+A typical use case is generating a connection URL for a database stack and exposing it so an external service (a git app, another stack) can reference it without having to reconstruct it manually.
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_USER: "{{ generate_username }}"
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+ DB_HOST: "{{ network_alias | 'postgres' }}"
+ DB_NAME: "{{ generate_slug }}"
+
+ # Exposed in the UI as an environment override
+ __DATABASE_URL: "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}"
+```
+
+The `__DATABASE_URL` variable won't be injected into any service automatically. It exists purely to surface the resolved value in the UI so you can copy it into another service's environment variables.
+
+
+
+---
+
+## Routing
+
+ZaneOps uses label-based routing instead of port mappings. There is no concept of published ports; traffic is routed through ZaneOps' reverse proxy based on `deploy.labels`.
+
+### Route labels
+
+```yaml title="docker-compose.yml"
+services:
+ web:
+ image: nginx:alpine
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "example.com"
+ zane.http.routes.0.port: "80"
+ zane.http.routes.0.base_path: "/"
+ zane.http.routes.0.strip_prefix: "false"
+```
+
+| Label | Required | Description |
+| --- | --- | --- |
+| `zane.http.routes.{N}.domain` | ✅ | Domain name to match |
+| `zane.http.routes.{N}.port` | ✅ | Container port to forward traffic to |
+| `zane.http.routes.{N}.base_path` | No | Path prefix (default: `/`) |
+| `zane.http.routes.{N}.strip_prefix` | No | Strip `base_path` before forwarding (default: `true`) |
+
+`{N}` is a zero-based sequential index. Add more routes by incrementing the index:
+
+```yaml title="docker-compose.yml"
+deploy:
+ labels:
+ zane.http.routes.0.domain: "example.com"
+ zane.http.routes.0.port: "8080"
+
+ zane.http.routes.1.domain: "www.example.com"
+ zane.http.routes.1.port: "8080"
+
+ zane.http.routes.2.domain: "api.example.com"
+ zane.http.routes.2.port: "3000"
+ zane.http.routes.2.base_path: "/api"
+ zane.http.routes.2.strip_prefix: "true"
+```
+
+Route domains can reference `x-zane-env` variables:
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ APP_DOMAIN: "{{ generate_domain }}"
+
+services:
+ web:
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "${APP_DOMAIN}"
+ zane.http.routes.0.port: "8080"
+```
+
+---
+
+## Unsupported properties
+
+The following standard Docker Compose properties are **silently stripped** before deployment:
+
+| Property | Reason |
+| --- | --- |
+| `expose` | Not meaningful in Swarm mode |
+| `restart` | Use `deploy.restart_policy` instead |
+| `build` | ZaneOps requires pre-built images |
+| `container_name` | Not meaningful in Swarm mode |
+
+### `ports`
+
+`ports` is not stripped and will work, but **it is strongly recommended to use routing labels instead**. Routing labels integrate with ZaneOps' reverse proxy to handle TLS termination, domain routing, and path-based routing automatically. Publishing raw ports bypasses all of that.
+
+```yaml title="docker-compose.yml"
+# ✅ Recommended: routing labels
+services:
+ web:
+ image: myapp:latest
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "myapp.com"
+ zane.http.routes.0.port: "8080"
+
+# ⚠️ Works, but gives up ZaneOps routing features
+services:
+ web:
+ image: myapp:latest
+ ports:
+ - "8080:8080"
+```
+
+---
+
+## Pausing a service
+
+Setting `deploy.replicas` to `0` pauses a service without removing it from the stack. Its status in ZaneOps becomes `SLEEPING`. Set it back to any positive number to resume.
+
+```yaml title="docker-compose.yml"
+services:
+ worker:
+ image: myapp/worker:latest
+ deploy:
+ replicas: 0 # paused; change to 1 or more to resume
+```
+
+This is useful for temporarily disabling background workers or non-critical services without tearing down the entire stack.
+
+---
+
+## Volumes
+
+Relative path bind mounts are **not supported** and will fail validation:
+
+```yaml title="docker-compose.yml"
+# ❌ Will fail
+volumes:
+ - ./config:/etc/app/config
+ - ../data:/app/data
+```
+
+Use inline configs for configuration files, or absolute paths for host directories:
+
+```yaml title="docker-compose.yml"
+# ✅ Inline config
+services:
+ web:
+ configs:
+ - source: app_config
+ target: /etc/app/config.json
+
+configs:
+ app_config:
+ content: |
+ { "key": "value" }
+
+# ✅ Absolute path
+services:
+ portainer:
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+```
+
+---
+
+## Docker configs
+
+ZaneOps supports inline configs using the standard `content` key. What ZaneOps adds on top is `${VAR}` interpolation inside config content and automatic versioning on redeploy.
+
+```yaml title="docker-compose.yml"
+services:
+ web:
+ image: nginx:alpine
+ configs:
+ - source: nginx_config
+ target: /etc/nginx/nginx.conf
+
+configs:
+ nginx_config:
+ content: |
+ server {
+ listen 80;
+ }
+```
+
+Config content supports `${VAR}` interpolation from `x-zane-env`:
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_HOST: "{{ network_alias | 'postgres' }}"
+
+configs:
+ app_config:
+ content: |
+ { "db_host": "${DB_HOST}" }
+```
+
+A common use case is shipping database initialization scripts:
+
+```yaml title="docker-compose.yml"
+# docker-compose.yml
+services:
+ postgres:
+ image: postgres:16
+ configs:
+ - source: init_sql
+ target: /docker-entrypoint-initdb.d/init.sql
+
+configs:
+ init_sql:
+ content: |
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+ CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ email TEXT UNIQUE NOT NULL
+ );
+```
+
+### Automatic versioning
+
+Docker configs are immutable, so ZaneOps appends a version suffix to every config name and manages the lifecycle automatically.
+
+
+
+
+| Deployment | `content` changed? | Config name used | Old config |
+| --- | --- | --- | --- |
+| 1st deploy | — | `nginx_config_v1` | — |
+| 2nd deploy | No | `nginx_config_v1` (reused) | unchanged |
+| 3rd deploy | **Yes** | `nginx_config_v2` (new) | not used anymore |
+
+
+
+
+
+---
+
+## Networks
+
+### Automatic network injection
+
+You don't need to declare networks for basic service communication. ZaneOps automatically attaches every service to three networks:
+
+
+| Network | Scope | Purpose |
+| --- | --- | --- |
+| `zane` (global overlay) | All ZaneOps services | Proxy and ZaneOps-internal communication |
+| Environment network (`zn-env-*`) | Same environment | Services in the same env can talk to each other |
+| Stack default network | Same stack | Services within the stack use their original names as hostnames |
+
+
+
+
+### Service name hashing
+
+ZaneOps prefixes all service names with a stack-specific hash to prevent DNS collisions across stacks:
+
+```
+# In your compose file: app, postgres
+# Deployed as: abc123_app, abc123_postgres
+```
+
+Because of this, **do not hardcode hashed names as hostnames**. Use one of these instead:
+
+- **Original service name**: works within the stack's default network:
+ ```yaml title="docker-compose.yml"
+ environment:
+ DB_HOST: postgres # resolves in the stack default network
+ ```
+
+- **`network_alias`**: works across services in the same environment:
+ ```yaml title="docker-compose.yml"
+ x-zane-env:
+ DB_HOST: "{{ network_alias | 'postgres' }}" # e.g. my-stack-postgres
+ ```
+
+- **`global_alias`**: for cross-stack or cross-environment references:
+ ```yaml title="docker-compose.yml"
+ x-zane-env:
+ DB_HOST: "{{ global_alias | 'postgres' }}" # e.g. abc123_postgres
+ ```
+
+---
+
+## `depends_on`
+
+ZaneOps converts the dict form of `depends_on` to a list for Docker Swarm compatibility:
+
+```yaml title="docker-compose.yml"
+# ❌ Dict form: converted automatically
+depends_on:
+ postgres:
+ condition: service_healthy
+
+# ✅ Equivalent after conversion
+depends_on:
+ - postgres
+```
+
+The `condition` field is dropped. `depends_on` controls startup ordering only; it does not wait for a service to become healthy or ready.
+
+---
+
+## Troubleshooting
+
+### Template expression not evaluated
+
+Template expressions only work inside `x-zane-env`:
+
+```yaml title="docker-compose.yml"
+# ❌ Not evaluated
+services:
+ app:
+ environment:
+ PASSWORD: "{{ generate_password | 32 }}"
+
+# ✅ Correct
+x-zane-env:
+ PASSWORD: "{{ generate_password | 32 }}"
+
+services:
+ app:
+ environment:
+ PASSWORD: ${PASSWORD}
+```
+
+### Unexpected empty value
+
+`${VAR}` references are always expanded. If the variable is not declared in `x-zane-env`, it expands to an empty string rather than keeping the literal `${VAR}` text. Make sure every variable you reference is defined.
+
+### Service can't connect to another service
+
+Don't use the hashed service name (`abc123_postgres`) as a hostname; it's an implementation detail. Use the original service name (works in the stack default network) or `network_alias` (works across the environment):
+
+```yaml title="docker-compose.yml"
+x-zane-env:
+ DB_HOST: "{{ network_alias | 'postgres' }}"
+```
+
+### Route validation failed
+
+Both `domain` and `port` are required per route. `strip_prefix` must be `"true"` or `"false"` (not `"yes"`/`"no"`).
+
+### Config not updated after redeployment
+
+ZaneOps only creates a new config version when the `content` changes. If the content is identical to the previous deployment, the existing version is reused. This is expected behavior.
+
+### `generate_password` invalid length error
+
+Length must be an even number ≥ 8 (e.g. 8, 10, 12, 16, 32, 64).
+
+
+---
+
+## Complete example
+
+PostgreSQL stack using generated credentials, a network alias, and an exposed connection URL:
+
+```yml title="docker-compose.yml"
+version: "3.8"
+x-zane-env:
+ # Generated once on first deploy and persisted
+ POSTGRES_PASSWORD: "{{ generate_password | 32 }}"
+ POSTGRES_USER: "{{ generate_slug }}"
+ POSTGRES_DB: "{{ generate_slug }}"
+
+ POSTGRES_VERSION: "18-alpine"
+
+ # Stable hostname for other services in the same environment to connect to
+ DB_HOST_ENV_NAME: "{{ network_alias | 'postgres' }}"
+
+ # Exposed in the UI so external services can copy the full connection string
+ __DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DB_HOST_ENV_NAME}:5432/${POSTGRES_DB}?schema=public"
+
+services:
+ postgres:
+ image: docker.io/library/postgres:${POSTGRES_VERSION:-18-alpine}
+ restart: unless-stopped
+ healthcheck:
+ # $$ escapes the $ so it's passed literally to the shell instead of being expanded by ZaneOps
+ test: ["CMD-SHELL", "pg_isready -d $$POSTGRES_DB -U $$POSTGRES_USER"]
+ start_period: 20s
+ interval: 30s
+ retries: 5
+ timeout: 5s
+ volumes:
+ - database:/var/lib/postgresql
+ environment:
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_DB: ${POSTGRES_DB}
+volumes:
+ database:
+ driver: local
+```
+
+
diff --git a/src/content/docs/knowledge-base/Docker compose/index.mdx b/src/content/docs/knowledge-base/Docker compose/index.mdx
new file mode 100644
index 00000000..09b5235b
--- /dev/null
+++ b/src/content/docs/knowledge-base/Docker compose/index.mdx
@@ -0,0 +1,90 @@
+---
+title: Overview
+description: Learn how to use docker compose in ZaneOps
+---
+
+import {Steps, Aside} from '@astrojs/starlight/components';
+
+> Introduced in [**v1.13.0**](/changelog/v113)
+
+ZaneOps supports deploying multi-container applications from `docker-compose.yml` files, with built-in blue/green deployments, web routing, and more.
+
+In ZaneOps, these are called **Compose Stacks**.
+
+
+### How does it work ?
+
+Under the hood, compose files are deployed as [Docker stacks](https://docs.docker.com/reference/cli/docker/stack/) via Docker Swarm, not plain `docker compose up`.
+
+This is an important distinction: Swarm is what enables ZaneOps to orchestrate rolling blue/green deployments, whereas standard Docker Compose has no concept of that.
+
+
+
+
+---
+
+### Ways to create a compose stack
+
+There are three ways to create a compose stack in ZaneOps:
+
+- **From a `docker-compose.yml` file**: paste your own compose file directly. [See below](#how-to-create-and-deploy-a-compose-stack).
+- **From a curated template**: pick from ZaneOps' library of ready-to-deploy stacks (Postgres, Redis, WordPress, and more) to get a pre-filled compose file. [**Browse all templates ↗**](/templates).
+ 
+ 
+- **From a Dokploy template** *(experimental)*: paste a base64-encoded Dokploy template and ZaneOps converts it to a native compose file automatically. [Learn more](./01-templates#dokploy-template-migration).
+ 
+
+
+
+---
+
+### How to create and deploy a Compose Stack
+
+
+1. In the ZaneOps dashboard go to your project and select **New > Compose Stack**
+ 
+2. Select **from docker-compose.yml**
+ 
+3. Paste your compose file
+ 
+4. Click **Create** and **Deploy**
+ 
+5. Your services are now running
+ 
+
+
+---
+
+### Compose file syntax
+
+ZaneOps adds some flavor on top of the normal compose file.
+
+- **Template expressions** for generating secrets, domains, and service aliases
+ ```yaml title="docker-compose.yml"
+ x-zane-env:
+ DB_PASSWORD: "{{ generate_password | 32 }}"
+ DB_NAME: "{{ generate_slug }}"
+
+ services:
+ db:
+ image: postgres:16
+ environment:
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_DB: ${DB_NAME}
+ ```
+- **Label-based routing** instead of port mappings for exposing services to the web
+ ```yaml title="docker-compose.yml"
+ services:
+ web:
+ image: nginx:alpine
+ deploy:
+ labels:
+ zane.http.routes.0.domain: "myapp.com"
+ zane.http.routes.0.port: "80"
+ ```
+- **Variable interpolation** using `${VAR}` syntax in env vars and configs
+- **Config versioning** for inline configuration files
+
+See the [Compose file syntax reference](./02-compose-file-reference) for the full details.
diff --git a/src/content/docs/knowledge-base/shared-volumes.mdx b/src/content/docs/knowledge-base/shared-volumes.mdx
new file mode 100644
index 00000000..3fa7ef9a
--- /dev/null
+++ b/src/content/docs/knowledge-base/shared-volumes.mdx
@@ -0,0 +1,8 @@
+---
+title: Shared volumes
+description: learn how to use shared volumes
+---
+
+> Introduced in [**v1.13.0**](/changelog/v113)
+
+> TODO...
\ No newline at end of file
diff --git a/src/content/docs/templates/index.mdx b/src/content/docs/templates/index.mdx
new file mode 100644
index 00000000..d95dcceb
--- /dev/null
+++ b/src/content/docs/templates/index.mdx
@@ -0,0 +1,10 @@
+---
+title: Templates Directory
+description: ZaneOps Templates Directory.
+template: splash
+hero:
+ title: 'ZaneOps Templates Directory'
+---
+import TemplatesSearch from "../../../components/templates/TemplatesSearch.astro";
+
+
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 00000000..15869614
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,57 @@
+export type TemplateDocument = {
+ id: string;
+ name: string;
+ description: string;
+ url: string;
+ tags: string[];
+ logoUrl: string;
+};
+
+export type TextMatchInfo = {
+ best_field_score: string;
+ best_field_weight: number;
+ fields_matched: number;
+ num_tokens_dropped: number;
+ score: string;
+ tokens_matched: number;
+ typo_prefix_score: number;
+};
+
+export type TemplateHit = {
+ document: TemplateDocument;
+ highlight: Record;
+ highlights: unknown[];
+ text_match: number;
+ text_match_info: TextMatchInfo;
+};
+
+export type SearchRequestParams = {
+ collection_name: string;
+ first_q: string;
+ per_page: number;
+ q: string;
+};
+
+export type TemplateSearchAPIResponse = {
+ facet_counts: unknown[];
+ found: number;
+ hits: TemplateHit[];
+ out_of: number;
+ page: number;
+ request_params: SearchRequestParams;
+ search_cutoff: boolean;
+ search_time_ms: number;
+};
+
+export type TemplateDetailsApiResponse = {
+ id: string;
+ name: string;
+ description: string;
+ tags: string[];
+ logoUrl: string;
+ githubUrl: string;
+ docsUrl: string;
+ websiteUrl: string;
+ url: string;
+ compose: string;
+};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 00000000..fd99dd72
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,19 @@
+import { type ClassNameValue, twMerge } from "tailwind-merge";
+
+export function durationToMs(
+ value: number,
+ unit: "seconds" | "minutes" | "hours" | "days" | "weeks"
+): number {
+ const multipliers = {
+ seconds: 1000,
+ minutes: 60 * 1000,
+ hours: 60 * 60 * 1000,
+ days: 24 * 60 * 60 * 1000,
+ weeks: 7 * 24 * 60 * 60 * 1000
+ };
+ return value * multipliers[unit];
+}
+
+export function cn(...inputs: ClassNameValue[]) {
+ return twMerge(inputs);
+}
diff --git a/src/pages/api-reference/openapi.astro b/src/pages/api-reference/openapi.astro
index 2256f851..1163b6b9 100644
--- a/src/pages/api-reference/openapi.astro
+++ b/src/pages/api-reference/openapi.astro
@@ -1,5 +1,5 @@
---
-import logo from "../../assets/Logo.svg";
+import logo from "~/assets/Logo.svg";
const ogImageUrl = new URL(`/api-reference/openapi/og.png`, Astro.site?.origin);
---
diff --git a/src/pages/templates/[slug].astro b/src/pages/templates/[slug].astro
new file mode 100644
index 00000000..15f84b39
--- /dev/null
+++ b/src/pages/templates/[slug].astro
@@ -0,0 +1,254 @@
+---
+import { TEMPLATE_API_HOST } from "astro:env/client";
+import { PRIVATE_TEMPLATE_API_HOST } from "astro:env/server";
+import { Code } from "@astrojs/starlight/components";
+import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
+import {
+ Book,
+ ChevronLeftIcon,
+ Github,
+ Globe,
+ LoaderIcon,
+ PencilLine,
+} from "@lucide/astro";
+
+import { TemplateCard } from "~/components/templates/template-search";
+import type {
+ TemplateDetailsApiResponse,
+ TemplateSearchAPIResponse,
+} from "~/lib/types";
+
+export const prerender = false;
+
+let t0 = performance.now();
+const response = await fetch(
+ new URL(
+ `/api/templates/${Astro.params.slug}.json`,
+ PRIVATE_TEMPLATE_API_HOST,
+ ),
+);
+console.log(
+ `[${Astro.params.slug}] fetch template details: ${(performance.now() - t0).toFixed(2)}ms`,
+);
+
+if (!response.ok) {
+ return new Response(null, { status: 404 });
+}
+const template: TemplateDetailsApiResponse = await response.json();
+
+const templateSearchUrl = new URL("/api/search", PRIVATE_TEMPLATE_API_HOST);
+templateSearchUrl.searchParams.set("per_page", "4");
+templateSearchUrl.searchParams.set("pick_random", "true");
+templateSearchUrl.searchParams.set("exclude_ids", template.id);
+
+for (const tag of template.tags) {
+ templateSearchUrl.searchParams.append("tagrs", tag);
+}
+
+t0 = performance.now();
+const { hits: templateList } = await fetch(templateSearchUrl).then(
+ (r) => r.json() as Promise,
+);
+console.log(
+ `[${Astro.params.slug}] fetch related templates: ${(performance.now() - t0).toFixed(2)}ms`,
+);
+
+const logoUrl = new URL(template.logoUrl, TEMPLATE_API_HOST);
+---
+
+
+
+
+
+
+ back to all templates
+
+
+
+
+
+
+
})
+
+
{template.name}
+
+
+
+
What is {template.name} ?
+
+ {template.description}
+
+
+
Template content
+
+
+
+
+
+ loading...
+
+
+
+
+
+
+
+
+
+
Links
+
+
+
Tags
+
+ {
+ template.tags.map((tag) => (
+
+ {tag}
+
+ ))
+ }
+
+
+
+
+
+
+ {
+ templateList.length > 0 && (
+
+ Related templates
+
+ {templateList.map(({ document }) => (
+
+ ))}
+
+
+ )
+ }
+
+
+