Skip to content

End-to-End Tutorial

This tutorial walks through a complete Dockform project from scratch. By the end, you'll have two services running on two separate servers, managed declaratively from a single manifest.

What We're Building

We'll deploy a small infrastructure across two Debian servers:

Server Stack Concepts Covered
debian-one Website — nginx serving static HTML Filesets, auto-discovery
debian-two Vaultwarden — self-hosted password manager Secrets (SOPS/age), volumes

Along the way we'll also cover targeted deployments, day-two updates, and as a bonus, adding Traefik as a reverse proxy.

tutorial/
├── dockform.yaml
├── debian-one/
│   └── website/
│       ├── compose.yaml
│       └── volumes/
│           └── html/
│               ├── index.html
│               └── css/
│                   └── style.css
└── debian-two/
    └── vaultwarden/
        ├── compose.yaml
        └── secrets.env          # SOPS-encrypted

Prerequisites

Before starting, make sure you have:

  • Two servers with Docker installed (any Linux distribution works)
  • SSH access to both servers from your workstation
  • Dockform installed — see Getting Started
  • SOPS and age installed — for encrypting secrets

No servers?

You can follow along with a single machine by using default as your only context and skipping the multi-context parts. The concepts are the same.


Step 1: Create the Project

Create a project directory and initialize it:

mkdir tutorial && cd tutorial
dockform init

This creates a starter dockform.yaml. Replace its contents with:

dockform.yaml
identifier: tutorial

contexts:
  debian-one:
    host: ssh://deploy@debian-one

That's all you need to start. The identifier labels every resource Dockform creates, and the contexts block tells Dockform which Docker daemons to manage.

Note

The host: field makes your manifest portable — anyone who clones this project can deploy without configuring Docker contexts first. You can also omit host: and use a pre-configured Docker context by matching the key name.

Create the Directory Structure

Create the website stack directory and its fileset:

mkdir -p debian-one/website/volumes/html/css

Dockform discovers stacks from the directory structure: each subdirectory under a context that contains a compose.yaml becomes a stack.


Step 2: Deploy a Website

The Compose File

Create a simple nginx compose file:

debian-one/website/compose.yaml
services:
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - html:/usr/share/nginx/html:ro

volumes:
  html:
    external: true

The html volume is declared as external: true — this tells Compose not to create it. Dockform manages it instead.

Add Content with Filesets

Create a simple landing page and stylesheet:

debian-one/website/volumes/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Website</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <main>
    <h1>Hello from Dockform</h1>
    <p>This page is synced via a fileset.</p>
  </main>
</body>
</html>
debian-one/website/volumes/html/css/style.css
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; background: #f8f9fa; }
main { text-align: center; }
h1 { margin-bottom: 0.5rem; }

Because these files live under volumes/html/, Dockform automatically discovers a fileset named html. It will:

  1. Create a Docker volume called html on debian-one
  2. Sync the contents of volumes/html/ into it
  3. Track changes via a content index so future syncs are incremental

Why not bind mounts?

Bind mounts with relative paths (e.g., ./html:/usr/share/nginx/html) don't work with remote Docker contexts — the path resolves on the server, not your workstation. Filesets solve this by transferring files to Docker volumes.

Preview the Plan

dockform plan
│ Context: debian-one
│ Identifier: tutorial

Stacks
  debian-one/website
    + nginx will be created

Filesets
  debian-one/website/html
    + 2 files to sync

Plan: 1 to create, 0 to change, and 0 to destroy

Dockform shows exactly what will happen: one service created and two files synced.

Apply

dockform apply

Dockform creates the volume, syncs the files, and runs docker compose up. Your website is now live at http://debian-one.


Step 3: Deploy Vaultwarden

Now let's add a second server with Vaultwarden, the self-hosted Bitwarden-compatible password manager.

Update the Manifest

Add debian-two as a new context with a volume for Vaultwarden's persistent data:

dockform.yaml
identifier: tutorial

contexts:
  debian-one:
    host: ssh://deploy@debian-one
  debian-two:
    host: ssh://deploy@debian-two
    volumes:
      vaultwarden-data: {}

Unlike fileset volumes (which are auto-discovered), the vaultwarden-data volume stores application data that Vaultwarden writes at runtime. We declare it explicitly so Dockform creates it before the stack starts.

Create the Stack

mkdir -p debian-two/vaultwarden
debian-two/vaultwarden/compose.yaml
services:
  vaultwarden:
    image: vaultwarden/server:1.32.7-alpine
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      DOMAIN: "https://vault.example.com"
      SIGNUPS_ALLOWED: "false"
    volumes:
      - vaultwarden-data:/data

volumes:
  vaultwarden-data:
    external: true

Plain environment variables like DOMAIN and SIGNUPS_ALLOWED go directly in the compose file — they're not sensitive. But Vaultwarden also needs an admin token and SMTP credentials. Those are secrets.

Encrypt Secrets with SOPS

Generate an age Key

If you don't have an age key yet:

mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Set the environment variable pointing to your key:

export AGE_KEY_FILE=~/.config/sops/age/keys.txt

Configure SOPS in the Manifest

Add the sops block:

dockform.yaml
identifier: tutorial

sops:
  age:
    key_file: ${AGE_KEY_FILE}

contexts:
  debian-one:
    host: ssh://deploy@debian-one
  debian-two:
    host: ssh://deploy@debian-two
    volumes:
      vaultwarden-data: {}

Create the Encrypted Secrets File

dockform secrets create debian-two/vaultwarden/secrets.env

This creates a SOPS-encrypted dotenv file. Edit it:

dockform secrets edit debian-two/vaultwarden/secrets.env

Your $EDITOR opens with the decrypted content. Add the secrets:

ADMIN_TOKEN=$argon2id$v=19$m=65540,t=3,p=4$your-hashed-admin-token
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_FROM=vault@example.com
SMTP_USERNAME=vault@example.com
SMTP_PASSWORD=your-smtp-password

Save and close. The file is re-encrypted automatically. You can safely commit it to Git — only someone with the age private key can decrypt it.

How secrets reach the container

Dockform decrypts secrets.env at deploy time and passes the values as inline environment variables to docker compose up. No temporary files are written to disk on the server.

Because the file is named secrets.env and lives inside the stack directory, Dockform discovers it automatically — no extra manifest configuration needed.

Deploy to debian-two Only

You don't need to redeploy the website. Target just the new context:

dockform apply --context debian-two

Dockform creates the vaultwarden-data volume, decrypts the secrets, and starts Vaultwarden. It's now accessible at http://debian-two:8080.


Step 4: Targeted Operations

Dockform supports three levels of targeting:

By Context

Deploy or plan changes for a single server:

dockform plan --context debian-one
dockform apply --context debian-two

By Stack

Target a specific stack:

dockform apply --stack debian-two/vaultwarden

By Deployment

Define named groups in your manifest for common operations:

dockform.yaml (excerpt)
deployments:
  website:
    description: Deploy the website only
    stacks:
      - debian-one/website
  all:
    description: Deploy everything
    contexts:
      - debian-one
      - debian-two

Then use:

dockform apply --deployment website

Tip

Use dockform plan with the same targeting flags to preview what would change before applying.


Step 5: Day-Two Updates

Updating the Website

Edit the HTML:

debian-one/website/volumes/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Website</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <main>
    <h1>Hello from Dockform</h1>
    <p>Updated: now with more content.</p>
  </main>
</body>
</html>

Preview the changes:

dockform plan --stack debian-one/website
│ Context: debian-one
│ Identifier: tutorial

Filesets
  debian-one/website/html
    ~ 1 file changed

Plan: 0 to create, 1 to change, and 0 to destroy

Dockform detects that only index.html changed. Apply:

dockform apply --stack debian-one/website

Only the changed file is synced — unchanged files are skipped entirely.

Updating Secrets

To rotate Vaultwarden's admin token:

dockform secrets edit debian-two/vaultwarden/secrets.env

Change the value, save, and redeploy:

dockform apply --stack debian-two/vaultwarden

Viewing Status

Launch the interactive dashboard:

dockform dashboard

This shows a live view of all containers across all contexts.


Bonus: Adding Traefik

A reverse proxy gives you TLS termination and domain-based routing. Here we'll add Traefik to debian-one. This section focuses on the Dockform parts — refer to the Traefik documentation for details on its configuration.

Create the Traefik Stack

mkdir -p debian-one/traefik/volumes/config
debian-one/traefik/compose.yaml
services:
  traefik:
    image: traefik:v3
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    networks:
      - traefik
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - config:/etc/traefik:ro
      - letsencrypt:/letsencrypt

networks:
  traefik:
    external: true

volumes:
  config:
    external: true
  letsencrypt:

The config volume is a fileset — Dockform syncs the Traefik configuration from volumes/config/. The letsencrypt volume is managed by Traefik itself (for storing ACME certificates) so it doesn't need to be external.

Add the Traefik Configuration

debian-one/traefik/volumes/config/traefik.yaml
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

providers:
  docker:
    exposedByDefault: false
    network: traefik

certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@example.com
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

Update the Manifest

Add a traefik network to debian-one:

dockform.yaml
identifier: tutorial

sops:
  age:
    key_file: ${AGE_KEY_FILE}

contexts:
  debian-one:
    host: ssh://deploy@debian-one
    networks:
      traefik: {}
  debian-two:
    host: ssh://deploy@debian-two
    volumes:
      vaultwarden-data: {}

Update the Website to Use Traefik

Replace port mapping with Traefik labels:

debian-one/website/compose.yaml
services:
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    networks:
      - traefik
    volumes:
      - html:/usr/share/nginx/html:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.website.rule=Host(`example.com`)"
      - "traefik.http.routers.website.entrypoints=websecure"
      - "traefik.http.routers.website.tls.certresolver=letsencrypt"
      - "traefik.http.services.website.loadbalancer.server.port=80"

networks:
  traefik:
    external: true

volumes:
  html:
    external: true

Deploy

dockform apply --context debian-one

Dockform creates the traefik network, syncs the Traefik config fileset, starts Traefik, and updates the website stack. Your site is now served over HTTPS.

Routing to debian-two

A single Traefik instance can route to services on other servers using file-based dynamic configuration. Define load balancer URLs pointing to debian-two's IP address — Traefik doesn't need to run on every server.


Full Project Reference

Final Directory Structure

tutorial/
├── dockform.yaml
├── debian-one/
│   ├── traefik/
│   │   ├── compose.yaml
│   │   └── volumes/
│   │       └── config/
│   │           └── traefik.yaml
│   └── website/
│       ├── compose.yaml
│       └── volumes/
│           └── html/
│               ├── index.html
│               └── css/
│                   └── style.css
└── debian-two/
    └── vaultwarden/
        ├── compose.yaml
        └── secrets.env              # SOPS-encrypted

Final Manifest

dockform.yaml
identifier: tutorial

sops:
  age:
    key_file: ${AGE_KEY_FILE}

contexts:
  debian-one:
    host: ssh://deploy@debian-one
    networks:
      traefik: {}
  debian-two:
    host: ssh://deploy@debian-two
    volumes:
      vaultwarden-data: {}

deployments:
  website:
    description: Deploy the website only
    stacks:
      - debian-one/website
  all:
    description: Deploy everything
    contexts:
      - debian-one
      - debian-two

What Dockform Manages

Resource Type Server How
html Volume (fileset) debian-one Auto-discovered from volumes/html/
config Volume (fileset) debian-one Auto-discovered from volumes/config/
traefik Network debian-one Declared in manifest
vaultwarden-data Volume debian-two Declared in manifest
Secrets Inline env debian-two Auto-discovered from secrets.env

Commands Cheatsheet

Command What it Does
dockform plan Preview all changes across both servers
dockform apply Apply all changes
dockform apply --context debian-one Deploy only to debian-one
dockform apply --stack debian-two/vaultwarden Deploy only Vaultwarden
dockform apply --deployment website Deploy the website group
dockform secrets edit <file> Edit encrypted secrets
dockform dashboard Live container monitoring
dockform destroy Tear down all managed resources

Next Steps