Back to Tutorials

Visual Compose Builder

Build a docker-compose.yml visually: add services, configure dependencies, ports, volumes, and environment variables with a live YAML preview.

Prerequisites

  • Docker is running
  • Pro subscription required (Compose Builder is a Pro feature)

Step 1: Add Services

  1. Click Compose in the sidebar
  2. Click Create New (or use the menu: File → New Compose Project)
  3. The Compose Builder opens with an empty canvas
  4. Click the + button ("Add Empty Service") — or pick from the Service Templates palette

Web Service (Node.js)

  1. Select the Node.js template from the palette
  2. The template pre-fills: service name node-js, image node:20-alpine, port 3000:3000, env NODE_ENV=development, and a command that creates a minimal HTTP server
  3. Rename the service to web if desired

Database Service (PostgreSQL)

  1. Click + again (use Add Empty Service to avoid pre-filled values)
  2. Configure the service:
    • Service Name: db
    • Image: postgres:16-alpine
    • Ports: 5432 5432
  3. Add environment variables:
    VariableValue
    POSTGRES_DBmyapp
    POSTGRES_USERpostgres
    POSTGRES_PASSWORDsecret
  4. Add a volume: click Add Volume, enter pgdata as the volume name and /var/lib/postgresql/data as the container path. Since pgdata doesn't start with / or ., the builder treats it as a named volume and adds it to the top-level volumes: section automatically.

Note: If you used the PostgreSQL template, it pre-fills POSTGRES_PASSWORD and POSTGRES_DB. Update those values instead of adding new entries with the same key — duplicate keys cause YAML parse errors.

Cache Service (Redis)

  1. Click + for cache:
    • Service Name: redis
    • Image: redis:7-alpine
    • Ports: 6379 6379

Step 2: Configure Dependencies

  1. Click the web service node
  2. In the Dependencies section, add:
    • db
    • redis
  3. Dependency arrows appear on the graph from web db and web redis

Step 3: Add Environment Variables

  1. Edit the web service
  2. Add environment variables:
    VariableValue
    DATABASE_URLpostgres://postgres:secret@db:5432/myapp
    REDIS_URLredis://redis:6379

Step 4: Preview and Save

  1. Click the YAML tab to see the generated docker-compose.yml
  2. Review the output:
docker-compose.yml
services:
  web:
    image: node:20-alpine
    command:
      - sh
      - -c
      - |
        node -e "require('http').createServer((req, res) => { res.writeHead(200); res.end('Hello from Node.js'); }).listen(3000, () => console.log('Server running on port 3000'))"
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DATABASE_URL: "postgres://postgres:secret@db:5432/myapp"
      REDIS_URL: "redis://redis:6379"
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: "secret"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Note: The Node.js template includes a built-in command that creates a minimal HTTP server, so all 3 services will start and stay running. You may see a Redis warning about vm.overcommit_memory — this is a known Docker Desktop / Lima VM limitation and can be safely ignored.

  1. Click Save and choose a directory
  2. Click Save & Build to start all services immediately

Step 5 (Optional): Mount a Startup Script

Instead of writing code inline in the Command field, you can use the Startup Script picker to mount and run a local file. Here's a connection-test script that verifies PostgreSQL and Redis are reachable, then serves the results as a web page on port 3000.

Create index.js in a local folder (e.g., ~/projects/my-app/index.js):

index.js
#!/usr/bin/env node
const net = require("net");
const { URL } = require("url");

const DATABASE_URL =
  process.env.DATABASE_URL || "postgres://postgres:secret@db:5432/myapp";
const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379";

function tcpConnect(host, port, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const sock = net.createConnection({ host, port }, () => { sock.end(); resolve(); });
    sock.setTimeout(timeoutMs);
    sock.on("timeout", () => { sock.destroy(); reject(new Error("Timed out")); });
    sock.on("error", reject);
  });
}

function pgHandshake(host, port, user, database, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const sock = net.createConnection({ host, port }, () => {
      const params = `user\0${user}\0database\0${database}\0\0`;
      const len = 4 + 4 + params.length;
      const buf = Buffer.alloc(len);
      buf.writeInt32BE(len, 0);
      buf.writeInt32BE(0x00030000, 4);
      buf.write(params, 8, "utf-8");
      sock.write(buf);
    });
    sock.setTimeout(timeoutMs);
    sock.once("data", (data) => { sock.end(); data[0] === 0x52 ? resolve() : reject(new Error("Bad response")); });
    sock.on("timeout", () => { sock.destroy(); reject(new Error("Timed out")); });
    sock.on("error", reject);
  });
}

function redisPing(host, port, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const sock = net.createConnection({ host, port }, () => { sock.write("PING\r\n"); });
    sock.setTimeout(timeoutMs);
    let response = "";
    sock.on("data", (d) => { response += d; if (response.includes("+PONG")) { sock.end(); resolve(); } });
    sock.on("timeout", () => { sock.destroy(); reject(new Error("Timed out")); });
    sock.on("error", reject);
  });
}

async function main() {
  let failures = 0;
  const checks = [];

  const dbUrl = new URL(DATABASE_URL);
  const dbHost = dbUrl.hostname, dbPort = parseInt(dbUrl.port || "5432", 10);
  const dbUser = dbUrl.username || "postgres", dbName = dbUrl.pathname.replace("/", "") || "myapp";
  const redisUrl = new URL(REDIS_URL);
  const redisHost = redisUrl.hostname, redisPort = parseInt(redisUrl.port || "6379", 10);

  console.log("\nConnection Test\n");

  for (const [label, fn] of [
    [`PostgreSQL TCP (${dbHost}:${dbPort})`, () => tcpConnect(dbHost, dbPort)],
    [`PostgreSQL handshake (user=${dbUser}, db=${dbName})`, () => pgHandshake(dbHost, dbPort, dbUser, dbName)],
    [`Redis TCP (${redisHost}:${redisPort})`, () => tcpConnect(redisHost, redisPort)],
    ["Redis PING", () => redisPing(redisHost, redisPort)],
  ]) {
    try { await fn(); checks.push({ label, ok: true }); console.log(`  ✓ ${label}`); }
    catch (e) { checks.push({ label, ok: false, error: e.message }); console.log(`  ✗ ${label} — ${e.message}`); failures++; }
  }

  const allPassed = failures === 0;
  console.log(allPassed ? "\nAll checks passed.\n" : `\n${failures} check(s) failed.\n`);

  const http = require("http");
  http.createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end("<!DOCTYPE html>...");
  }).listen(3000, () => console.log("Results at http://localhost:3000"));
}

main().catch((e) => { console.error("Fatal:", e); process.exit(1); });

Mount it using the Startup Script picker

  1. Click the web service to open the editor
  2. Clear the Command field (so the startup script takes effect)
  3. In the General section, find Startup Script (optional) below the Command field
  4. Click Browse... and select the index.js file
  5. The builder auto-detects the interpreter (.js node) and shows: "Will be mounted at /scripts/index.js and run on startup"
  6. Switch to the YAML tab — the script is mounted as a read-only volume and set as the command:
docker-compose.yml (web service)
  web:
    image: node:20-alpine
    command: "node /scripts/index.js"
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DATABASE_URL: "postgres://postgres:secret@db:5432/myapp"
      REDIS_URL: "redis://redis:6379"
    volumes:
      - "/Users/you/projects/my-app/index.js:/scripts/index.js:ro"
    depends_on:
      - db
      - redis
  1. Click Save & Build — the container runs the connection tests and serves results at http://localhost:3000

Note: If you have an explicit Command set, it takes priority over the startup script. Clear the Command field to let the startup script run instead. To remove the startup script, click the x button next to the file name.

What You'll See

  • The builder canvas shows service nodes as cards that can be positioned
  • Dependency arrows connect services using polyline (straight-segment) routing
  • The YAML tab updates in real-time as you modify services
  • Validation warnings appear for issues like duplicate port mappings or missing images

Tips

  • You can add services from the builder's template picker — it has the same 19+ service templates as the container template library
  • Use the Startup Script picker to mount and run a local script file instead of typing commands inline — supports .sh, .js, .py, .rb, and .go
  • The builder uses collapsible form sections (not tabs) for configuring ports, volumes, env vars, and dependencies
  • YAML values containing special characters (colons, at-signs) are automatically quoted
  • The graph uses polyline routing for dependency arrows — straight segments that avoid intersecting nodes
  • Use the Compose Intelligence feature (Pro) for validation, security scanning, and auto-fix suggestions

Related Tutorials