Metadata-Version: 2.4
Name: forje-cloud
Version: 0.1.1
Summary: Idempotent server provisioning: Docker Swarm, Traefik and Cloudflare Tunnel, with rollback, backups and AI-explained alerts.
Author-email: Matheus Rodrigues Trindade <contact@forjelo.com>
Maintainer-email: Matheus Rodrigues Trindade <contact@forjelo.com>
License: MIT
License-File: LICENSE
Keywords: backup,cloudflare,cloudflared,devops,docker,homelab,idempotent,infrastructure,monitoring,provisioning,self-hosted,swarm,traefik,tunnel,vps
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
Classifier: Topic :: System :: Installation/Setup
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# forje-cloud

[![PyPI](https://img.shields.io/pypi/v/forje-cloud.svg)](https://pypi.org/project/forje-cloud/)
[![Python](https://img.shields.io/pypi/pyversions/forje-cloud.svg)](https://pypi.org/project/forje-cloud/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](#no-dependencies)
[![Platform](https://img.shields.io/badge/platform-Debian%20%7C%20Ubuntu-orange.svg)](#requirements)
[![Status](https://img.shields.io/badge/status-preview-yellow.svg)](#status-of-this-release)

**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.

```bash
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.

```yaml
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

```bash
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

```bash
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

```bash
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

```bash
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.

```bash
$ 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`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
and add two lines to `~/.ssh/config`:

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

Then connect as you always have:

```bash
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](https://developers.cloudflare.com/cloudflare-one/policies/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](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)
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.1 is a preview, suitable for use and feedback.**

The tool is extensive, and each deployment scenario varies. A lab cannot cover
every one of them, so I am counting on feedback from real use to map out further
corrections and adjustments.

I publish every version directly to PyPI — v0.1.0 is there, this one joins it,
and the next will follow. That serves two purposes: transparency, and making sure
testing happens on real machines rather than only in a lab.

Preview releases are always minor, so the contract will vary:

| Version | What to expect |
|---|---|
| **`0.1.*`** | Stabilisation. The contract may change between releases. |
| **`0.2.0`** | A firmer contract, and a more stable tool. |
| **`1.0.0`** | A fixed contract, with managed updates and instrumentation for migration. |

None of this prevents use. It describes what to expect as the tool matures.

This release follows the first complete bootstrap of a real machine — from a clean
install through to applications served over HTTPS through the tunnel, with backups
running and monitoring reporting. The provisioning model works on a live machine,
not only on paper.

That run surfaced what a green test suite cannot: environment-specific behaviour,
integration edges, and places to be more careful before acting. All of it is
addressed here, and each change is pinned by a test. The suite has grown from 116
tests to 291, and runs entirely offline.

Recommended for experienced operators, on machines you are comfortable rebuilding.

### Known limitations

- **Restore and prune have not yet been exercised end to end.** They depend on a
  completed backup cycle, which is now possible. The code paths are unit-tested
  and ready for validation.
- **The monitor cannot report its own machine going down.** 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](https://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:

- **contact@forjelo.com**
- **matheusrodriguestrindade3@gmail.com**

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

---

## License

MIT © Matheus Rodrigues Trindade