Skip to content
← ProjectsInfraOpsIn production

My whole hosting platform described as code: one app = one YAML file.

Role
Architect and operator
Period
2026
Context
Hosts Jobspark, Jémima and this site
PaaS as code

1

file per app

2

environments per app

35 j

retention in production

0

destructive plans accepted

Context

docker-compose stacks deployed by hand, inconsistent volume names, uneven backups across projects.

The challenge

Standardise without breaking anything running in production, and make every server rebuildable from the repo.

  1. 01Infra

    One manifest per app

    Versioned JSON schema: apps, workers, databases, caches, environments, domains. A Terraform module turns it into Dokploy resources, one service per component.

  2. 02Ops

    Data safe by default

    Daily backup of every database and volume to R2. Deploys refuse any plan that destroys a stateful resource; deleting an environment archives its volumes first.

  3. 03Ops

    Adoption without recreation

    An existing production Dokploy is taken over through Terraform import: Jobspark and Jémima migrated with no downtime.

  4. 04Infra

    Multi-server

    Adding a machine = one Ansible playbook + one line in nodes.yaml. Automatic purge of images and build cache.

Architecture

Stack

  • DDokploy
  • Terraform / OpenTofu
  • Ansible
  • Docker
  • Cloudflare (DNS, R2)
  • GitHub Actions
  • SPSauvegardes & PRA
  • Linux

Results

  • ✦Staging on every push, production on demand
  • ✦Smoke test after every deploy
  • ✦Server rebuildable from the repo and backups

The story

How it works

This is the operator’s path, which means mine: from the repo to an app online, then the day you want to remove something.

1. One repo, one layer per folder

GitHub page of the josias-koffi/infra repo: ansible, apps, docs, examples, modules/dokploy-app, platform, schema, scripts, templates folders
The public repo: one layer per folder, from servers (ansible) to apps (apps, modules), plus the manifest contract (schema).

Everything lives in a public repo. ansible/ prepares the servers (Docker, Dokploy at a pinned version, purge). platform/ describes the Dokploy instance itself: settings, backup destinations, registry, remote servers. apps/project and apps/env are the two OpenTofu roots run for each app, and modules/dokploy-app turns a manifest into resources. An app repo only holds its .deploy/manifest.yaml and a workflow of a few lines.

2. A push to develop deploys staging

GitHub Actions run graph: resolve, 4-image build matrix, API image boot, Deploy / resolve, Deploy / deploy, deployed version check
A push to develop in the Jobspark repo: the app repo only adds its builds and checks, the rest comes from the reusable infra workflow.

The first job, resolve, is a reusable workflow: it reads the manifest and decides whether this event deploys, to which environment, and with which tag. Here Jobspark builds its four images, boots the API image to check it starts, then calls deploy.yml. The last job compares the version served by the API with the deployed SHA.

3. A push to main deploys nothing

GitHub Actions run on main: resolve succeeded, every other job skipped, notice “production is deploy: manual”
A push to main does not deploy: resolve reads `deploy: manual` in the manifest, stops, and says so in a notice.

Production is deploy: manual. The run stops after resolve and leaves a notice. To ship, I run the workflow by hand from main, with the tag already validated in staging. The same button does rollbacks: you pass an older tag.

4. The plan is read before it is applied

scripts/deploy-app.sh validates the manifest, checks DNS, applies the project, then plans the environment. I replayed that plan on this site’s manifest with the mocked provider from the module tests: eleven resources for the portfolio’s staging, without any connection to Dokploy.

infra/modules/dokploy-app

The manifest is all there: three applications (front, CMS, MinIO), their three HTTPS domains, the Postgres database and its backup, the MinIO volume and its backup, and the hashes of the locked secrets. Cloudflare DNS records are added in apps/env, outside the module.

5. Deleting takes another path

“Delete a whole environment” section of the purge guide: repository, environment, confirm, dry_run, delete_volumes inputs
Deleting goes through a separate workflow: typed confirmation, dry run by default, required reviewer, and volumes archived first.

Removing a database from the manifest does not delete it: the deploy refuses. To remove a whole environment there is the purge-env workflow. It needs a reviewer and a typed <app>/<env> confirmation, and it runs as a dry run the first time. It archives every volume on the server before destroying anything.

6. Taking over what exists without recreating it

docs/case-studies/vps20-migration.md: Outcome table with safety net, platform, Jemima, Jobspark, deploy rules
The log of the production VPS takeover, versioned with the code: no container restarted, same volumes, same row counts.

The Jobspark and Jémima stacks were already running on Dokploy as docker-compose. The platform took them over through import: the composes moved to their new environment in place, without restarting a container. The full log is in docs/case-studies, with the map of the volumes actually mounted.

Under the hood

From manifest to resources

What deploy-app.sh does for one environment

Each step can stop the rest. Validation checks the JSON schema, then consistency: a uses entry must name a known component, every domain must exist in every environment. check-dns refuses a name already taken by a record the platform did not create. apps/project and apps/env have separate states, one per environment, stored in R2.

The guard against destructive plans

What happens between plan and apply

The plan is saved to a file, then read back as JSON. If it deletes a stateful resource, the script stops. It does the same if the SHA-256 hash of a locked secret changes: a database only reads its password when its volume is created, so changing the variable later would break connections. A deliberate rotation goes through ALLOW_SECRET_CHANGE, one name at a time.

scripts/deploy-app.sh (excerpt)
plan = json.load(sys.stdin)
changes = plan.get("resource_changes", [])
stateful = ("dokploy_postgres", "dokploy_mysql", "dokploy_mariadb", "dokploy_mongo",
            "dokploy_redis", "dokploy_compose", "dokploy_mount")
bad = [r["address"] for r in changes
       if r["type"] in stateful and "delete" in r["change"]["actions"]]
if bad:
    print("Refusing to destroy stateful resources:", *bad, sep="\n  ")
    print("Use the purge-env workflow (protected) for an intended removal.")
    sys.exit(1)

To check it, I removed the database from the portfolio manifest in a copy of the module. The mocked plan does propose to destroy Postgres and its backup, and the guard, taken verbatim from the script, refuses it:

scratchpad

Several servers

Topology described by platform/nodes.yaml

platform/nodes.yaml lists the servers. Today there is only one, vps20, as manager: it hosts the Dokploy panel and the services. A second server is prepared with make add-node, then declared with the remote role; Dokploy drives it over SSH and it keeps its own Traefik. An app picks its machine with server:. There is no network between servers: an app and its dependencies live in the same place.

Tests

make test validates the example manifests, runs the module tests against a mocked provider and validates the three roots. The tests check details that mattered in production, for example that Jobspark keeps the prefix of its real volumes and the historical layout of its R2 backups.

infra

What I take away

  • Check Dokploy before trusting it. On a throwaway project I found that deleting a compose deletes its named volumes, unless they are external. Hence externalVolumes: true being mandatory in compose mode, and tofu state rm rather than destroy to release old states. These findings are written down in docs/reference/dokploy-behaviour.md.
  • Put the rules in the manifest, not in the workflow. An app’s workflow decides nothing. Each environment’s branch and its auto or manual mode sit in the same file as the services, and get read in code review.
  • Split states per environment. A staging apply can neither read nor change production. It is a bit more plumbing (read-only terraform_remote_state), but it is what let me migrate staging first, with no risk to production.

Gallery

GitHub page of the josias-koffi/infra repo: ansible, apps, docs, examples, modules/dokploy-app, platform, schema, scripts, templates folders
docs/case-studies/vps20-migration.md: Outcome table with safety net, platform, Jemima, Jobspark, deploy rules
“Delete a whole environment” section of the purge guide: repository, environment, confirm, dry_run, delete_volumes inputs
Koklo
Next project →KokloThe OS for AI-assisted development: from idea to deploy, under control.

Let’s talk. Describe your need in a few lines, I’ll get back to you within 48 hours.