Skip to content

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.

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.

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.

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 at
https://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 and
docker-compose.yml on the box is a separate step, covered in the same guide's
"After the instance is up" section.

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.