MadeEasy
Run the whole platform on your own infrastructure from prebuilt images, step by step. No source code required.
You do not have to run anything yourself. MadeEasy is available as a managed cloud service, kept up to date and monitored by us. Start there and self-host later if you need to.
This guide walks a systems administrator through deploying MadeEasy on your own servers. You pull a prebuilt image from our registry, provide configuration, and start it. There is no source checkout. Two supported paths:
kubectl and Helm 3.vector extension. For the best knowledge-base search (BM25), use ParadeDB, which bundles it. The Docker Compose bundle runs this for you automatically; for Kubernetes you point at your own database.pypi.org, files.pythonhosted.org and api.osv.dev (see Step 6.4).MadeEasy for production requires a signed license key. Request one by emailing support.info@agenticauto.net or through Book a demo. You will receive a single long string; keep it secret. You will paste it into your configuration as MADEEASY_LICENSE_KEY with LICENSE_ENFORCEMENT=true.
LICENSE_ENFORCEMENT=false. Production use requires a valid key.On a fresh Ubuntu/Debian host:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # log out and back in so this takes effect
docker version # confirm Docker is running
mkdir -p ~/madeeasy && cd ~/madeeasy
docker-compose.ymlSave the following as docker-compose.yml in that folder:
services:
db:
image: paradedb/paradedb:latest
restart: unless-stopped
environment:
POSTGRES_USER: madeeasy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: madeeasy
volumes: [db-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U madeeasy -d madeeasy"]
interval: 5s
timeout: 5s
retries: 20
api:
image: ${MADEEASY_IMAGE:-ghcr.io/cauchymike/madeeasy:latest}
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql://madeeasy:${POSTGRES_PASSWORD}@db:5432/madeeasy
SECRETS_PROVIDER: env
SERVICE_ROLE: api
ports: ["${API_PORT:-8000}:8000"]
volumes: [files:/files]
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 60s
worker:
image: ${MADEEASY_IMAGE:-ghcr.io/cauchymike/madeeasy:latest}
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql://madeeasy:${POSTGRES_PASSWORD}@db:5432/madeeasy
SECRETS_PROVIDER: env
SERVICE_ROLE: worker
volumes: [deps:/deps, files:/files]
depends_on:
db: { condition: service_healthy }
volumes:
db-data:
deps:
files:
api serves the app and, on start, applies database migrations and the task-queue schema; worker runs background jobs and waits for the api to finish provisioning, so no manual ordering is needed. Both mount the same files volume, where the files your processes keep are stored; back it up with the database.Run these and copy each value for the next step:
# Master key that encrypts per-process secrets:
docker run --rm python:3.11-slim sh -c "pip -q install cryptography && python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
# Two signing secrets: one for sign-in, one for the platform operator (run twice):
openssl rand -base64 48
.envSave this as .env in the same folder and fill in every value:
# Registry image (pin to a released tag in production, e.g. :1.4.0)
MADEEASY_IMAGE=ghcr.io/cauchymike/madeeasy:latest
# Database password for the bundled Postgres
POSTGRES_PASSWORD=choose-a-long-random-password
# Public host port the API listens on
API_PORT=8000
# License (from Step 1)
LICENSE_ENFORCEMENT=true
MADEEASY_LICENSE_KEY=paste-your-license-key
# Secrets (from Step 2.4)
PROCESS_SECRETS_MASTER_KEY=paste-fernet-key
AUTH_JWT_SECRET=paste-first-openssl-value
AUTH_ENABLED=true
# Platform operator (super admin) accounts, see Step 5 (second openssl value)
SUPER_ADMIN_SIGNING_KEY=paste-third-openssl-value
# LLM provider (at least one). You can move these into MadeEasy later (Step 6).
DEFAULT_LLM_PROVIDER=gemini
GEMINI_API_KEY=your-gemini-key
# GEMINI_DEFAULT_MODEL=gemini-3.1-flash-lite # the model used where an agent names none
# OPENAI_API_KEY=your-openai-key
# Where files that outlive a run are kept (the files volume in docker-compose.yml)
WORKSPACE_REMOTE=folder
WORKSPACE_BUCKET=/files
# Tool calls: the most memory one call may use, in bytes (large spreadsheets need more).
# Keep it below the memory the container has. 0 sets no limit.
# TOOL_ISOLATED_MEMORY_BYTES=3221225472
# Worker + runtime tool dependencies (leave as-is)
WORKER_CONCURRENCY=5
WORKER_WATCHDOG_TIMEOUT_SECONDS=120
DEPENDENCY_INSTALLER=pip_target
DEPENDENCY_TARGET_DIR=/deps
KNOWLEDGE_BM25_MODE=auto
# If you serve the UI on a public domain, set it here (Step 4)
# API_CORS_ORIGINS=https://app.yourcompany.com
.env private. Never commit it or share it.docker compose up -d # pulls images and starts db, api, worker
docker compose ps # all should be "running"/"healthy"
docker compose logs -f api # watch the api migrate and come up (Ctrl-C to stop)
Confirm the API is healthy:
curl -fsS http://localhost:8000/health # -> {"status":"ok"} (200)
Use this instead of Compose if you run Kubernetes. You will need kubectl pointed at your cluster and Helm 3. This path expects an external PostgreSQL (with vector, ideally ParadeDB for BM25).
Save as my-values.yaml:
image:
repository: ghcr.io/cauchymike/madeeasy
tag: "latest" # pin to a release in production
database:
url: "postgresql://user:password@your-db-host:5432/madeeasy"
license:
enforce: true
key: "paste-your-license-key"
secrets:
processSecretsMasterKey: "paste-fernet-key"
authJwtSecret: "paste-openssl-value-1"
superAdminSigningKey: "paste-openssl-value-2"
llm:
defaultProvider: gemini
geminiApiKey: "your-gemini-key"
# Expose on your domain via your ingress controller (bring your own TLS)
ingress:
enabled: true
className: "nginx"
host: "app.yourcompany.com"
tls:
enabled: true
secretName: "madeeasy-tls"
helm install madeeasy ./madeeasy -n madeeasy --create-namespace -f my-values.yaml
# Watch the rollout
kubectl -n madeeasy rollout status deploy/madeeasy-madeeasy-api
kubectl -n madeeasy rollout status deploy/madeeasy-madeeasy-worker
database.url at your own PostgreSQL. Contact us for the chart package if you do not have it.The image ships with the management commands. Create your first admin from inside the running api container.
Docker Compose:
docker compose exec api python scripts/create_admin.py \
--username you@yourcompany.com \
--email you@yourcompany.com \
--full-name "Your Name"
# You will be prompted for a password (or pass --password).
Kubernetes:
kubectl -n madeeasy exec deploy/madeeasy-madeeasy-api -- \
python scripts/create_admin.py --username you@yourcompany.com --email you@yourcompany.com --full-name "Your Name" --password 'a-strong-password'
Now open the app and sign in with that account.
With Docker Compose the API is exposed on a plain host port (API_PORT). Serve it on your own domain over HTTPS with whatever reverse proxy you already run. Two examples:
server {
server_name app.yourcompany.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# add TLS (e.g. with certbot)
}
app.yourcompany.com {
reverse_proxy 127.0.0.1:8000
}
Then set API_CORS_ORIGINS=https://app.yourcompany.com in .env and run docker compose up -d again. On Kubernetes, the Ingress in your values file handles this.
X-Forwarded-For (the nginx snippet above does; Caddy does automatically). The app trusts it to see the real client IP, which it uses for per-IP rate limiting and request logging; otherwise every request looks like it comes from the proxy."127.0.0.1:${API_PORT:-8000}:8000" so it is not reachable directly from the internet.The super admin looks after the infrastructure MadeEasy runs on. When something goes wrong there (a misconfigured key, the database, a provider being down), your users see only a short message with a reference such as ME-AF72AC82; the details go to the super admin on the Platform health page.
It is deliberately separate from your admins:
You set SUPER_ADMIN_SIGNING_KEY in Step 2. The api and the worker must have the same value. Optional: SUPER_ADMIN_MAX (default 2) and SUPER_ADMIN_SESSION_MINUTES (default 60). To receive the security alerts, configure email (the MAIL_* settings); without it everything works and is recorded, but no alert is sent.
Docker Compose:
docker compose exec api python scripts/create_super_admin.py \
--username ops --email you@yourcompany.com
Kubernetes:
kubectl -n madeeasy exec -it deploy/madeeasy-madeeasy-api -- \
python scripts/create_super_admin.py --username ops --email you@yourcompany.com
--email mailbox receives the security alerts.Any authenticator app that makes time-based 6-digit codes works: Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden and others.
From then on, each sign-in asks for the password and then the current code. A super-admin sign-in lasts at most 60 minutes. If a code is refused, wait for the next one and make sure your phone sets its clock automatically.
# Show how many super admins are active and whether a recovery key is set
docker compose exec api python scripts/create_super_admin.py --status
# Add a second one (asks for the recovery key, prints a new one)
docker compose exec api python scripts/create_super_admin.py --username ops2 --email someone@yourcompany.com
# Remove one (signs it out everywhere at once)
docker compose exec api python scripts/create_super_admin.py --revoke ops2
# Replace the recovery key (if anyone may have seen it)
docker compose exec api python scripts/create_super_admin.py --rotate-recovery-key
DELETE FROM platform_recovery_key;. The next create then prints a new key. Check the trail and revoke any account you don't recognise.MadeEasy works with the settings above. These make it ready for real use. Change any of them in .env (or extraEnv in your Helm values) and run docker compose up -d again.
Instead of keeping provider keys in .env, store them in MadeEasy, encrypted under your master key, so an administrator can replace or test one without a restart:
# Copy the keys from the configuration into MadeEasy, encrypted, and test each one
docker compose exec api python scripts/org_llm_keys.py import
docker compose exec api python scripts/org_llm_keys.py status # every provider should read as working
Then add LLM_KEY_SOURCE=organization to .env, run docker compose up -d, check that runs and the assistant work as before, and remove the *_API_KEY lines from .env. From then on keys are set and tested in the app, on the organization's page under AI providers, and never shown again once saved.
With Docker Compose, files are kept in the files volume (set in Step 2). On Kubernetes, use a cloud bucket: set WORKSPACE_REMOTE to s3, gcs or azure_blob and WORKSPACE_BUCKET to the bucket's name through extraEnv, with the bucket's credential in your secrets. An organization can also be given its own storage in the app (organization page, File storage). For people to upload very large files straight from the browser to a bucket, allow your MadeEasy address, the method PUT and the header Content-Type in the bucket's CORS settings; otherwise uploads go through MadeEasy. WORKSPACE_THROUGH_UPLOAD_MAX_BYTES caps how large a file MadeEasy will carry itself (0, the default, sets no cap).
API_DOCS_ENABLED=true opens it, for development only) and the metrics, which are served only with METRICS_TOKEN (set one if you scrape metrics).RATE_LIMIT_GENERAL_PER_MINUTE, RATE_LIMIT_WORK_PER_MINUTE, RATE_LIMIT_ANONYMOUS_PER_MINUTE, RATE_LIMIT_PASSWORD_RESET_PER_HOUR); RATE_LIMIT_ENABLED=false turns them off.AUTH_PASSWORD_MIN_LENGTH), and checked against known breached passwords. Only the first five characters of a scrambled form of the password leave the server. On a server with no internet access, set AUTH_PASSWORD_BREACH_CHECK=false.AUTH_JWT_TTL_MINUTES, 720) and end at once, everywhere, when a user's password or role changes or they are switched off.SECURITY_CSP_ENFORCE=true. Put MadeEasy on HTTPS first (Step 4).API_MAX_BODY_BYTES); file uploads have their own limits.Every call of a tool your processes use runs in a process of its own, as a separate system user (toolcall, set up in the image) with none of MadeEasy's settings. It reaches only its own process's files and the credentials its code names, connects only to the hosts its code names, and is stopped at its time and memory limits. Nothing needs setting for this, with one condition:
SETUID and SETGID capabilities. The worker needs them to start each call as toolcall. Docker gives them by default. On Kubernetes, do not drop them in a security context; a cluster that enforces the "restricted" pod security standard drops every capability, and every tool call is then refused. A tool never runs as the worker's own user instead.deps volume, so they survive a restart. If the check cannot be made, nothing is installed.Two checks are always on: a file's type is judged from its content (a chat attachment must really be a photo, recording, video, PDF, Office document or text, so a program renamed photo.png is refused), and PDF and Word documents are read in a process of their own, with no network and a time and memory limit (DOCUMENT_READ_SECONDS, DOCUMENT_READ_MEMORY_BYTES).
Malware scanning is optional; MadeEasy ships no scanner of its own. Choose one:
MALWARE_SCANNER=icap, MALWARE_SCANNER_HOST and, from your product's documentation, MALWARE_SCANNER_ICAP_SERVICE and MALWARE_SCANNER_ICAP_MODE (respmod or reqmod). Nothing extra runs.docker compose --profile scanning up -d and set MALWARE_SCANNER=clamav and MALWARE_SCANNER_HOST=clamav; with Helm set scanning.scanner=clamav and scanning.clamav.enabled=true. It keeps its virus signatures in memory (about 1.5 GB) and updates them daily from database.clamav.net.With a scanner set, every uploaded file is scanned before it is kept or read. An infected file is refused with the threat's name and recorded on the health page with who sent it. A file the scanner cannot check is refused for now, never let through, so keep the scanner running. A large file sent straight to your storage bucket is held until it is checked; the Files tab shows it as being checked.
Each organization can let its invited people sign in with their work account (Google Workspace, Microsoft, Okta, or any OpenID Connect provider), set up by its administrator in Admin → Company sign-in. Nothing is configured on the server for it; it needs outbound HTTPS to the organization's provider and working DNS lookups (each organization confirms its email domain with a DNS record, checked against your server's own DNS).
An installation starts with one organization, and that is all most need. If one installation must keep several organizations' data apart (for example several companies in a group), MadeEasy can enforce it in the database itself. It is a deliberate, reversible change in four steps, made with scripts/org_scoping.py; contact support for the walkthrough before you start.
By default MadeEasy reads configuration from environment variables (SECRETS_PROVIDER=env). It can instead read from a managed vault:
SECRETS_PROVIDER=aws and give the workload an IAM role (or IRSA on EKS) that can read the secrets.SECRETS_PROVIDER=azure and AZURE_VAULT_URL, with access via workload identity.Store DATABASE_URL, MADEEASY_LICENSE_KEY, PROCESS_SECRETS_MASTER_KEY, the auth secrets, SUPER_ADMIN_SIGNING_KEY and your provider keys in the vault by name. In Helm, set config.secretsProvider=aws (or azure) and leave the secrets, database.url and license.key values empty; no Kubernetes Secret is created in that mode.
Docker Compose: change MADEEASY_IMAGE to the new tag, then:
docker compose pull && docker compose up -d
Kubernetes:
helm upgrade madeeasy ./madeeasy -n madeeasy --set image.tag=NEW_TAG -f my-values.yaml
The api role applies any new database migrations automatically on start. Pin to explicit released tags rather than latest so upgrades are deliberate.
Your instance also serves an embeddable chat widget, so you can put any of your conversational processes on your own website as a chat bubble, with no front-end build required. The widget script is served by your deployment at /widget.js.
</body> on your site:<script src="https://app.yourcompany.com/widget.js"
data-process="YOUR_PROCESS_TYPE"
data-token="PASTE_THE_TRIGGER_TOKEN"
data-title="Support"
data-accent="#4f7cff"></script>
| Symptom | Likely cause & fix |
|---|---|
| API container exits immediately with a license message | Missing or invalid MADEEASY_LICENSE_KEY while LICENSE_ENFORCEMENT=true. Paste the key, or set enforcement off for evaluation. |
/health not responding yet | On first start the api migrates the database; give it up to a minute. Check docker compose logs api. |
| Worker keeps restarting at first boot | Normal until the api has provisioned the database and queue schema; it settles on its own. |
| Platform health says "Platform operator privileges required" for the super admin | SUPER_ADMIN_SIGNING_KEY is missing or differs from the one the account was created with. Set the original value on the api and worker, or revoke and recreate the account. |
| Two-factor code is always refused | The phone's clock is off. Set it to update automatically, then use the next code. |
| Uploaded files or a run's reports disappear after the run | No file store is set. Set WORKSPACE_REMOTE and WORKSPACE_BUCKET on the api and the worker (Step 2.5 or 6.2), with the same folder or bucket for both. |
| "Too many requests" during normal work | A request limit is too low for your use. Raise the relevant RATE_LIMIT_* setting (Step 6.3). |
| A new password is refused as known from a breach, on a server with no internet | The check could not run as intended. Set AUTH_PASSWORD_BREACH_CHECK=false, or allow outbound access to api.pwnedpasswords.com. |
| A run on a large spreadsheet is stopped "over its memory limit" | The tool needed more than TOOL_ISOLATED_MEMORY_BYTES. Raise it, staying below the container's memory, or give the host more memory. |
| Uploads are refused: "could not be checked for malware just now" | A scanner is set (MALWARE_SCANNER) but is not answering. Check that it is running and reachable at MALWARE_SCANNER_HOST; the health page's "Uploaded files are scanned for malware" check says why. ClamAV takes a minute or two to load its signatures after a start. |
| Every tool call fails with "This worker cannot start a tool call as 'toolcall'" | The worker lost its SETUID/SETGID capabilities, usually a Kubernetes security context or pod security policy that drops all capabilities. Allow those two on the worker (Step 6.4). |
| Approving a tool says the packages "couldn't be checked for known vulnerabilities" | The server cannot reach api.osv.dev. Allow outbound access to it, and to pypi.org and files.pythonhosted.org, then approve again. |
| A run's tool reports "Network access refused" | The tool tried to reach a host its code does not name, or a private address. Write the service's full address into the tool's code (or into a secret it reads) and approve the change. |
| "A credential must be set" for a provider | Keys are kept in MadeEasy (LLM_KEY_SOURCE=organization) and this organization has none for that provider. Set it on the organization's page under AI providers. |
| Cannot pull the image | Check outbound access to ghcr.io. For a private image, run docker login ghcr.io first. |
Questions or a license request: support.info@agenticauto.net. Prefer not to run it yourself? MadeEasy Cloud is ready to go.