Deploying to AWS
Zilean ships no AWS-specific artifacts — it’s the same Docker Compose topology from
Installation and Deploying, run on
an EC2 instance instead of bare metal or a VM elsewhere. This page covers instance sizing
and a starting Terraform config; the Compose file, .env, and reverse-proxy routing are
unchanged from the guides above.
Recommended EC2 settings
Section titled “Recommended EC2 settings”Sizing is driven by the mem_limit ceilings in Deploying, not
by a generic scheduler sizing guide. Three tiers, depending on which script workers you
actually run:
| Minimum (core + ui + worker only) | + script workers (Bash / Node) | Full stack, incl. Groovy | |
|---|---|---|---|
| Container ceilings | 512m+512m+512m = 1.5 GB | +256m/+512m = ~2.3–2.8 GB | +1024m (Groovy) = ~3.3 GB |
| Instance type | t4g.small (2 vCPU / 2 GiB) or t4g.medium for headroom |
t4g.medium (2 vCPU / 4 GiB) |
t4g.large (2 vCPU / 8 GiB) |
| AMI | Amazon Linux 2023, arm64 | Amazon Linux 2023, arm64 | Amazon Linux 2023, arm64 |
| Storage | 20 GiB gp3 | 30 GiB gp3 | 50 GiB gp3 |
Postgres itself is light at Zilean’s scale (a job-scheduling schema, not an
application-data warehouse) and needs no separate sizing beyond what’s already budgeted
above — but if you expect a large job history or heavy worker_metrics retention, move it
to RDS rather than growing the instance further.
Sample Terraform
Section titled “Sample Terraform”A single EC2 instance running the full Compose stack end to end: a security group exposing
only what the reverse-proxy routing in Deploying requires, and
user_data that installs Docker, writes .env and docker-compose.yml from the reference
topology in Installation, and brings the stack up in the
required order (zilean-postgres → zilean-core → everything else) on first boot.
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }}
provider "aws" { region = var.aws_region}
variable "aws_region" { default = "us-east-1"}
variable "instance_type" { description = "t4g.medium for core+ui+worker (+ Bash/Node); t4g.large once Groovy is enabled" default = "t4g.large"}
variable "ssh_allowed_cidr" { description = "CIDR allowed to reach port 22 — never leave this at 0.0.0.0/0" type = string}
variable "zilean_version" { description = "Image tag to deploy — pin to vX.Y.Z, never latest" default = "v1.0.0"}
variable "db_password" { description = "Password for the zilean Postgres role" type = string sensitive = true}
variable "admin_username" { description = "Super Admin login — must be an email address; zilean-core refuses to start otherwise" type = string}
variable "admin_password_hash" { description = "bcrypt hash for the Super Admin password — generate with the htpasswd command in Installation; never pass the plaintext password itself" type = string sensitive = true}
data "aws_ami" "al2023_arm64" { most_recent = true owners = ["amazon"]
filter { name = "name" values = ["al2023-ami-*-arm64"] }
filter { name = "architecture" values = ["arm64"] }}
resource "aws_security_group" "zilean" { name = "zilean" description = "Zilean host — HTTP(S) from anywhere, SSH restricted"
ingress { description = "HTTPS (reverse proxy)" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { description = "HTTP (reverse proxy — redirect to HTTPS)" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { description = "SSH" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.ssh_allowed_cidr] }
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_instance" "zilean" { ami = data.aws_ami.al2023_arm64.id instance_type = var.instance_type vpc_security_group_ids = [aws_security_group.zilean.id]
root_block_device { volume_type = "gp3" volume_size = 30 encrypted = true }
# Secrets land in .env via user_data below, which IMDSv2 restricts to # processes on the instance itself — not a public artifact, but still # plaintext on disk. See the caution after this block. metadata_options { http_tokens = "required" }
user_data = <<-EOF #!/bin/bash set -euo pipefail
dnf update -y dnf install -y docker systemctl enable --now docker usermod -aG docker ec2-user
mkdir -p /usr/local/lib/docker/cli-plugins curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-aarch64" \ -o /usr/local/lib/docker/cli-plugins/docker-compose chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
mkdir -p /opt/zilean/postgres/data
cat > /opt/zilean/.env <<'ENVFILE' DATABASE_URL=postgres://zilean:${var.db_password}@zilean-postgres:5432/zilean?sslmode=disable ADMIN_USERNAME=${var.admin_username} ADMIN_PASSWORD_HASH=${var.admin_password_hash} SCRIPT_SANDBOX_ENABLED=1 ENVFILE
cat > /opt/zilean/docker-compose.yml <<'COMPOSE' services: zilean-postgres: image: postgres:17.10-alpine restart: unless-stopped env_file: [.env] volumes: - ./postgres/data:/var/lib/postgresql/data
zilean-core: image: ghcr.io/patrickpingol/zilean-slim:${var.zilean_version} command: ["/usr/local/bin/zilean-core"] restart: unless-stopped env_file: [.env] depends_on: - zilean-postgres
zilean-ui: image: ghcr.io/patrickpingol/zilean-slim:${var.zilean_version} command: ["/usr/local/bin/zilean-ui"] restart: unless-stopped env_file: [.env] depends_on: - zilean-core
zilean-worker: image: ghcr.io/patrickpingol/zilean-slim:${var.zilean_version} command: ["/usr/local/bin/zilean-worker"] restart: unless-stopped env_file: [.env] depends_on: - zilean-core
zilean-mcp: image: ghcr.io/patrickpingol/zilean-slim:${var.zilean_version} command: ["/usr/local/bin/zilean-mcp"] restart: unless-stopped # Zero-key service: a narrowed environment, not env_file. It never # receives SETTINGS_ENCRYPTION_KEY or SCRIPT_SECRETS_KEY and refuses to # start if either is set. environment: DATABASE_URL: $${DATABASE_URL} TZ: $${TZ:-UTC} MCP_ADDR: $${MCP_ADDR:-:8092} depends_on: - zilean-core
zilean-webhook: image: ghcr.io/patrickpingol/zilean-slim:${var.zilean_version} command: ["/usr/local/bin/zilean-webhook"] restart: unless-stopped # Zero-key service: a narrowed environment, not env_file. It never # receives SETTINGS_ENCRYPTION_KEY or SCRIPT_SECRETS_KEY and refuses to # start if either is set. environment: DATABASE_URL: $${DATABASE_URL} TZ: $${TZ:-UTC} WEBHOOK_ADDR: $${WEBHOOK_ADDR:-:8093} WEBHOOK_TRUST_PROXY_HEADERS: $${WEBHOOK_TRUST_PROXY_HEADERS:-false} depends_on: - zilean-core
# Always-on hardening on all three script workers, plus the two # security_opt relaxations that SCRIPT_SANDBOX_ENABLED=1 above needs — # see Deploying's "Container confinement settings". zilean-script-worker-bash: image: ghcr.io/patrickpingol/zilean-scripts-bash:${var.zilean_version} restart: unless-stopped env_file: [.env] read_only: true tmpfs: - /tmp:size=128m,noexec,nosuid,nodev cap_drop: - ALL security_opt: - seccomp:unconfined - systempaths:unconfined - no-new-privileges:true depends_on: - zilean-core
zilean-script-worker-node: image: ghcr.io/patrickpingol/zilean-scripts-node:${var.zilean_version} restart: unless-stopped env_file: [.env] read_only: true tmpfs: - /tmp:size=256m,noexec,nosuid,nodev cap_drop: - ALL security_opt: - seccomp:unconfined - systempaths:unconfined - no-new-privileges:true depends_on: - zilean-core
zilean-script-worker-groovy: image: ghcr.io/patrickpingol/zilean-scripts-groovy:${var.zilean_version} restart: unless-stopped env_file: [.env] read_only: true tmpfs: - /tmp:size=256m,noexec,nosuid,nodev cap_drop: - ALL security_opt: - seccomp:unconfined - systempaths:unconfined - no-new-privileges:true depends_on: - zilean-core COMPOSE
cd /opt/zilean docker compose pull docker compose up -d zilean-postgres docker compose up -d zilean-core docker compose up -d zilean-ui zilean-worker zilean-mcp zilean-webhook \ zilean-script-worker-bash zilean-script-worker-node zilean-script-worker-groovy EOF
tags = { Name = "zilean" }}
output "public_ip" { value = aws_instance.zilean.public_ip}The tmpfs sizes above are charged against each worker’s memory limit; see
Container hardening for how to size them
against mem_limit.
Only the script runtimes you actually plan to use need to stay in the Compose file — drop
the zilean-script-worker-* blocks (and SCRIPT_SANDBOX_ENABLED if you’re running none of
them) for tiers that don’t need them, per the sizing table above.
Swap docker-compose-linux-aarch64 for docker-compose-linux-x86_64 (and the AMI filter’s
arm64 for x86_64) if you deploy to a t3-class instance instead.
AI prompt for provisioning this
Section titled “AI prompt for provisioning this”If you’d rather have an AI coding agent write and apply the Terraform for you than copy the snippet above by hand, paste this in as-is — it points the agent back at this page for sizing and asks for a properly structured, production-shaped Terraform layout rather than a single-file starting point:
Provision an EC2 host for running Zilean (a self-hosted job scheduler) with Terraform,following the sizing and networking guidance athttps://zilean.dev/guides/deploying-to-aws/.
Requirements:- Ask me which of that page's three sizing tiers applies (core+ui+worker only; + Bash/Node script workers; full stack including Groovy) before choosing an instance type, rather than assuming one.- Use an Amazon Linux 2023 arm64 AMI (Graviton) unless I say otherwise, resolved via an aws_ami data source — never a hardcoded AMI ID.- Split the config into main.tf, variables.tf, outputs.tf, and versions.tf rather than one file, with a required_providers block pinning both the AWS provider and Terraform's own version constraint.- Configure a remote backend (S3 with native S3 state locking, or S3 + DynamoDB if the Terraform version predates native locking) instead of local state.- Restrict SSH (port 22) to a CIDR I supply as a variable — never default it to 0.0.0.0/0 — and expose only 80/443 publicly.- Use a gp3, encrypted root volume sized to the chosen tier.- Tag every resource consistently: at minimum Name, Environment, and ManagedBy = "terraform".- Never hardcode secrets (DB password, ADMIN_PASSWORD_HASH, etc.) into the Terraform config or user_data. Surface them as sensitive variables, or point me at AWS Secrets Manager / SSM Parameter Store instead.- Run terraform fmt and terraform validate on the result, and show me the plan before applying anything.
Scope this to the instance and networking only — provisioning .env anddocker-compose.yml on the box is a separate step, covered in the same guide's"After the instance is up" section.After the instance is up
Section titled “After the instance is up”If you used the sample Terraform above, .env and docker-compose.yml are already on the
instance and the stack is already up in the required order — jump straight to
Deploying’s “Verifying a deploy”.
If you used the AI prompt instead (deliberately scoped to the instance and networking only),
follow Installation from “Set the required configuration”
onward — create .env and docker-compose.yml on the instance, then bring core up first
so it applies migrations before the other services start.