Skip to main content

Idempotent server provisioning: Docker Swarm, Traefik and Cloudflare Tunnel, with rollback, backups and AI-explained alerts.

Project description

forje-cloud

PyPI Python License: MIT Dependencies Platform Status

A production server, from a bare Debian box, in one command.

Containers, HTTPS, backups, monitoring and hardening — provisioned, verified, and documented. Every step checks what already exists and leaves it alone.

pip install forje-cloud
forje-cloud setup

What you get

A server that publishes applications over HTTPS with every inbound port closed. All traffic arrives through an outbound tunnel: Zero Trust by design.

Docker Swarm Encrypted secrets, rolling updates, room to add machines.
Traefik Routes hostnames to containers, using labels.
Cloudflare Tunnel A cloudflared container holds one outbound connection that carries all inbound traffic. The firewall stays closed.
Wildcard DNS + TLS *.example.com resolves and is certificated once. Every application deployed afterwards is a routing label and nothing else.
Local image registry Build on the machine, deploy from the machine.
Backups to Cloudflare R2 Volumes, databases and images — off the machine, supervised, with retention.
AI-explained monitoring Container logs and the host journal are classified; anything serious is explained by Claude or GPT and delivered to Telegram.
Hardening Firewall, intrusion blocking, key-only SSH, zero open ports.
Tamper-evident audit log Every entry carries the hash of the one before it.
Self-documenting Every run writes an INFRASTRUCTURE.md describing the machine as it is.

Why

Provisioning scripts assume a clean machine. Real machines are rarely clean.

Docker is installed, but not in Swarm mode. A reverse proxy is running, but as a plain container. A tunnel was set up months ago. The deploy user exists but is not in the docker group.

forje-cloud checks before it acts. Every step answers "does this already exist, and is it correct?" before touching anything. What is already correct is left untouched.


Zero Trust by design

Every port on the server is closed. A container named cloudflared runs on the host network and opens a single outbound connection to Cloudflare. Inbound requests travel back down that connection. Nothing listens on a public port — not HTTP, not HTTPS, not SSH.

browser ──HTTPS──> Cloudflare ──tunnel──> cloudflared ──HTTP──> Traefik ──> app
                       │                       │
                  TLS ends here          outbound only,
                                        no port is opened

TLS terminates at Cloudflare. Inside the machine everything speaks plain HTTP, so Traefik never holds a certificate, never contacts a certificate authority, and never needs port 443.

There is no IP-and-port to attack, because there is no IP-and-port. The machine is addressed by hostname, and the edge decides what reaches it.

Provisioning wires this together in three parts:

  1. The tunnel is created through the Cloudflare API, and its ingress rules are configured to forward to http://127.0.0.1:80 — the port Traefik publishes on the loopback address. Configuring the ingress rules is what makes the tunnel carry traffic.

  2. Two DNS records are created, both pointing at the tunnel: the apex (example.com) and the wildcard (*.example.com). The wildcard is the reason a new application needs no DNS work at all — any subdomain already resolves, and already has a certificate.

  3. The cloudflared container receives the connector token as a root-only file rather than a command-line flag, so docker inspect and ps cannot read it.

Deploying an application afterwards

Once the tunnel and the wildcard exist, publishing a new application is five labels. No DNS record, no certificate, no port.

services:
  myapp:
    image: 127.0.0.1:5000/myapp:20260711-1420
    networks: [edge]
    deploy:
      # Traefik reads these from the Swarm service, not the container.
      labels:
        - "traefik.enable=true"
        - "traefik.swarm.network=edge"
        - "traefik.http.routers.myapp.rule=Host(`myapp.example.com`)"
        - "traefik.http.routers.myapp.entrypoints=web"
        - "traefik.http.services.myapp.loadbalancer.server.port=8000"

networks:
  edge:
    external: true

docker stack deploy, and https://myapp.example.com is live.


Guided setup

forje-cloud setup

Every question explains what the value is for, in one line, without jargon. Every section can be skipped — skipping backups means no backups, not a crash. Nothing is executed until the configuration is shown and confirmed.

── Public access (Cloudflare) ────────────────────────────
   Cloudflare puts your apps online without opening any port on
   this machine. It also provides HTTPS certificates automatically.

 ? Publish this server through Cloudflare? [Y/n]

 ? Domain
   A domain you own, already added to Cloudflare. Example: example.com
   >

The other commands

forje-cloud status    # what this machine already has
forje-cloud plan      # what a run would change, without changing it
forje-cloud apply     # provision from flags and environment, no prompts
forje-cloud restore   # bring back volumes, databases and images
forje-cloud compose   # reconstruct a Compose file for every running workload
forje-cloud verify    # check the audit log for tampering
forje-cloud docs      # regenerate the infrastructure document

A run on a partly-provisioned machine:

 • hostname        already 'example-host'
 • deploy-user     account 'deploy' exists
 • docker          Docker version 29.1.3
 ✔ docker-group    added 'deploy' to the docker group
 ✔ swarm           initialized (advertise address 192.0.2.10)
 • network         overlay 'edge' exists
 ○ registry        will be configured
 ▲ traefik         running traefik:v2.11, expected traefik:v3.6

already present · configured now · will be configured · exists but diverges · not requested · failed

Six distinct outcomes rather than two. "Missing", "not requested" and "diverges from what was expected" are different answers, and reporting them separately is what lets you read a run at a glance. A clean machine shows a column of : everything still to do, nothing broken.


Drift is not absence

A resource that exists but differs from what was expected is reported as drift, and the run halts at that step rather than overwriting it.

The distinction is load-bearing. Traefik v2 and v3 read different label syntax: v3 uses a dedicated swarm provider and reads traefik.swarm.network, where v2 used docker with swarmMode: true and read traefik.docker.network. The two are not interchangeable — v3 labels on a v2 proxy are ignored, which leaves an application running but unrouted, with no error anywhere.

So a proxy on the wrong major version is not a gap to fill. It is a running system whose every routing label would have to be rewritten at once. That is a migration, and a migration is a decision for the operator.

forje-cloud reports drift and stops — on a real run, not only in a dry run. The run names what differs and suggests what to do. Nothing is overwritten without a decision.

Every step verifies its own result

An exit status of 0 is not proof of a working system. docker run returns 0 for a container that then begins crash-looping. docker stack deploy returns 0 for a service whose provider cannot reach the daemon.

So every step is asked a second question after it acts: did that actually work? Traefik is asked whether its provider is alive. The tunnel is asked whether its container is running steadily rather than restarting. The backup service is asked whether its supervisor came up. A step that cannot confirm its own result is treated as a failure and rolled back.


Backups

A backup stored on the machine it protects is not a backup. Archives are uploaded to Cloudflare R2, off the machine.

Three things are captured, because restoring any two of them is not enough:

  • Volumes — archived with zstd, which is faster and smaller than gzip.
  • Databases — dumped with each engine's own tool. Postgres, MySQL, MariaDB, MongoDB and Redis are recognised by image family, so postgres:16-alpine and pgvector/pgvector are both handled. Dumping with the engine's own tool guarantees a consistent archive, where copying live database files can catch them mid-write.
  • Images — the local registry. Without them, a rebuilt machine has stack files referencing images that exist nowhere.

The service is supervised: a failed cycle is retried, and if the process dies Swarm restarts it. The schedule is configurable — daily by default, with retention in days.

Every backup carries a manifest naming what it holds, so restore reads it rather than inferring intent from filenames.

Restoring

forje-cloud restore

Pick a backup, then pick what to bring back — by number, by range, or all. Recovering a machine that ran eight workloads means dozens of volumes, and batch selection is what keeps that a single operation.

Reconstructing a Compose file

forje-cloud compose

Servers accumulate stacks deployed from files that were never committed anywhere. This reads the running services back out of Swarm and reconstructs a single Compose file covering every workload — images, volumes, networks, secrets, routing labels.

A copy is written into every backup. After a total loss: restore the archives, deploy the file, and the machine is back.


Monitoring

Two sources are read on a schedule, because half the things that go wrong with a server do not happen inside a container:

Source Read with What it catches
Container logs docker logs Application errors, crash loops, 5xx responses, database corruption.
The host journal journalctl, by a systemd timer on the host SSH brute force, fail2ban bans, the kernel OOM killer, disk and filesystem errors.

An SSH brute-force attempt does not appear in docker logs, and neither does a disk that is filling up. Reading both sources is what makes this a view of the machine rather than only of the applications running on it.

Lines are matched against patterns and scored for severity. Only findings above the threshold are escalated, and those go to Claude or GPT, which answers three questions: what happened, what likely caused it, and what to do about it. The answer arrives on Telegram.

Each finding carries the platform it came from, so the advice refers to docker logs for a container and journalctl for the host, rather than suggesting a host-level command for a containerised service.

Raw logs never leave the machine. Only the classified findings are sent, which are smaller, cheaper, and do not carry whatever a log line happened to contain.

Identical problems are suppressed for a cooldown window, so one incident produces one alert rather than fifty. If the model's provider becomes unavailable, a circuit breaker gives it time to recover rather than retrying every cycle, and the alert is still delivered — without the explanation.

The instructions given to the model are a file on the server, not code. Edit it and redeploy to change how alerts are written.

The monitor runs on the machine it monitors, so an external heartbeat is the right companion for detecting the machine itself going down. That heartbeat is on the roadmap.


An audit log you can trust

Every entry carries the hash of the entry before it.

$ forje-cloud verify
✖ audit log   entry #4 (step=firewall) was modified: hash does not match

Editing or deleting any past record breaks the chain, which makes tampering evident.


It documents itself

Every run writes INFRASTRUCTURE.md: a diagram of how traffic reaches an application, what is running, what an application must declare to be deployed here, and what must never appear in its Compose file.

Written from the results of the run, so it describes the machine as it is.


Reaching the machine with every port closed

The tunnel carries SSH on its own hostname — ssh.your-domain.com — alongside the HTTP it carries for your applications. Cloudflare routes by hostname, and the connector hands the connection to localhost:22 from inside the machine, where the firewall does not apply.

This is Zero Trust applied to administrative access: the shell is reached by name, through the edge, with no port exposed to the internet.

On the machine you connect from, install cloudflared and add two lines to ~/.ssh/config:

Host ssh.your-domain.com
  ProxyCommand cloudflared access ssh --hostname %h

Then connect as you always have:

ssh deploy@ssh.your-domain.com

Your key authenticates you exactly as before. cloudflared carries the connection; it does not replace the key, and nothing about how you log in changes.

Optionally, put Cloudflare Access in front of that hostname to require authentication at the edge before a connection reaches the machine at all. Configure it in the Cloudflare dashboard; forje-cloud does not manage it.

Validate the route before closing port 22. The step will refuse to run if the SSH route is not in place, and confirming the hostname works from your own terminal is the last check worth making.


Safety

The guards below are enforced by the tool, not left to the operator.

Key-only SSH requires a key. Most providers hand out a root password rather than asking for a key, so a fresh machine often has none. The step reads authorized_keys for the deploy account and root, refuses if both are empty, and prints the ssh-copy-id command that fixes it.

Closing port 22 requires a working alternative. The step confirms with Cloudflare that the tunnel is connected and carries SSH on its own hostname. A tunnel that routes only HTTP is not accepted as sufficient.

Destructive steps require confirmation. Always explicit, never implied.

Credentials are never written down. The sudo password is passed on stdin, never in argv (visible through ps) and never in the environment (visible through /proc). Service credentials become Docker secrets, encrypted at rest. Nothing is written to the audit log or the generated document.

Sudo is scoped. The deploy account gets NOPASSWD for an enumerated list of commands, never ALL.

Configuration is validated before it is loaded. sshd -t and visudo -cf run first. An invalid file is reverted, not applied.


Requirements

  • A Debian-based Linux machine — Debian or Ubuntu. Bare metal, VM or VPS.
  • Python 3.9 or newer.
  • Root, or an account with sudo.
  • Optional, for public access: a domain on Cloudflare.
  • Optional, for backups: a Cloudflare R2 bucket.
  • Optional, for alerts: a Telegram bot and an Anthropic or OpenAI API key.

The Cloudflare token

One custom token, with two permissions:

Account  ->  Cloudflare One Connector: cloudflared  ->  Edit
Zone     ->  DNS                                    ->  Edit

The Zone row appears once a zone has been picked under Zone Resources. A token missing it will create the tunnel and then fail to create the DNS record that points at it.

Cloudflare's API token guide is the authority if their dashboard differs from the above.

No dependencies

Standard library only. A tool that provisions a machine should not require the machine to be provisioned first.


Status of this release

v0.1.2 is available for general use. Earlier releases were previews; this one is not.

The setup path has been validated end to end across thirteen rounds against thirteen freshly created machines, each one starting from a clean Ubuntu install: the wizard answered as an operator would answer it, the provisioning run carried through to applications served over HTTPS through the tunnel, and the resulting machine inspected against its real state rather than against the tool's own report. Every round destroys its machine, so the next one begins from nothing.

That process is what closed the remaining gaps from 0.1.1. Backups now complete and are exercised, restore brings volumes, databases and images back onto a live machine, and prune enforces retention against the bucket. Drift, rollback, and the guards around SSH were driven repeatedly rather than reasoned about.

Releases remain minor while the contract settles:

Version What to expect
0.1.* Available and validated. The contract may still change between releases.
0.2.0 A firmer contract, and a broader validated surface.
1.0.0 A fixed contract, with managed updates and instrumentation for migration.

Thirteen machines are not every machine. Distributions, kernels, Docker versions and pre-existing configuration vary in ways a lab does not reproduce, so variation is expected and reports of it are the fastest way to narrow the gap.

Known limitations

  • Disaster recovery has not been exercised from end to end. Restore and prune are validated against a live machine; rebuilding a lost machine from its backups alone — a new host, the archives, the reconstructed Compose file, nothing else — has not been driven as a single sequence. Every component of that sequence works individually.
  • The monitor cannot report its own machine going down. It runs on the machine it monitors. An external heartbeat is on the roadmap.

Forjelo Labs

forje-cloud is part of the Forjelo technology stack. More of the work, and what comes next, is at forjelo.com/labs.


Contributing

Contributions are welcome — corrections, criticism of the design, or an account of what happened when the tool met a real machine.

A public repository is not yet available, so the way in is email:

Patches, findings and questions all land in the same place.


License

MIT © Matheus Rodrigues Trindade

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

forje_cloud-0.1.3.tar.gz (99.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

forje_cloud-0.1.3-py3-none-any.whl (108.4 kB view details)

Uploaded Python 3

File details

Details for the file forje_cloud-0.1.3.tar.gz.

File metadata

  • Download URL: forje_cloud-0.1.3.tar.gz
  • Upload date:
  • Size: 99.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for forje_cloud-0.1.3.tar.gz
Algorithm Hash digest
SHA256 5d12deb74b45e5c46d0d1d147e704833cb7b428ddee4483fadb4bb80d1062592
MD5 c2d349c244329ccf8833eb99a3551191
BLAKE2b-256 3c34bf8eb5745e1e593b5d45b21347b934c101da21e5fa290b76a45cc73c299f

See more details on using hashes here.

File details

Details for the file forje_cloud-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: forje_cloud-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 108.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for forje_cloud-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 bc02b034344ad9f092eeb0f41547a9610da680b19adfb5b59b55013fecf0a0c5
MD5 6805951767373274004a9e3997bdbb06
BLAKE2b-256 37e4d73f4ae20c194ef3f0f967e632f7d778a23b9c333373932865b00fdfe029

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page