NexusCore

Self-hosted developer tools so you can stop renting cron jobs, webhook routers, and glue scripts. Buy once, run on your own machines, keep...
Profile picture
Alfredo BustamanteProfile picture@alfredobustamante33·4d
Pinned post

Replacing EasyCron with self-hosted NexusCron

Replacing EasyCron with self-hosted NexusCron


Move scheduled HTTP jobs off EasyCron onto a scheduler you run. Job definitions, logs, and secrets stay on machines you control.


NexusCron license:


What you are replacing


EasyCron typically stores:


  • Job URL, HTTP method, headers, and body

  • Cron expression and timezone

  • Retry / timeout settings

  • Execution history in their dashboard


NexusCron runs the same shape of work as containers: a scheduler, a worker, Postgres for state, Redis for the queue.


Architecture


Internet / your apps
        |
     Caddy or Nginx (TLS)
        |
   nexuscron-api :8080
        |
   +----+----+
   |         |
 Postgres   Redis
   |         |
   +----+----+
        |
  nexuscron-worker (N replicas)


Prerequisites


  • Docker Engine 24+ and Docker Compose v2

  • PostgreSQL 15+ (or the bundled Postgres service)

  • A domain (cron.example.com)

  • 1 vCPU / 1 GB RAM minimum (2 GB if you keep months of logs)

  • Outbound HTTPS from workers

  • EasyCron API token (read-only is enough to export)


1. Inventory EasyCron jobs


curl -sS "https://www.easycron.com/rest/list?token=$EASYCRON_TOKEN" \
  | jq '.cron_jobs[] | {id, cron_job_name, cron_expression, url, http_method, timezone}'


Keep the JSON dump. Map fields 1:1 into NexusCron.


2. Layout and secrets


mkdir -p /opt/nexuscron/{data/postgres,data/redis,config,backups}
cd /opt/nexuscron


/opt/nexuscron/.env (never commit this):


NEXUSCRON_ENV=production
NEXUSCRON_LOG_LEVEL=info
NEXUSCRON_TZ=Europe/Madrid
NEXUSCRON_PUBLIC_URL=https://cron.example.com
NEXUSCRON_API_HOST=0.0.0.0
NEXUSCRON_API_PORT=8080
NEXUSCRON_API_TOKEN=replace-with-long-random-token
NEXUSCRON_JWT_SECRET=replace-with-64-char-random
POSTGRES_USER=nexuscron
POSTGRES_PASSWORD=replace-with-strong-password
POSTGRES_DB=nexuscron
DATABASE_URL=postgres://nexuscron:replace-with-strong-password@postgres:5432/nexuscron?sslmode=disable
REDIS_URL=redis://redis:6379/0
REDIS_PASSWORD=replace-with-redis-password
NEXUSCRON_WORKER_CONCURRENCY=8
NEXUSCRON_HTTP_TIMEOUT_SECONDS=60
NEXUSCRON_MAX_RETRIES=3
NEXUSCRON_RETRY_BACKOFF=exponential
NEXUSCRON_ALLOW_PRIVATE_TARGETS=false
NEXUSCRON_MAX_BODY_BYTES=1048576
NEXUSCRON_CORS_ORIGINS=https://cron.example.com


openssl rand -hex 32


3. Compose


Pin images to a digest in production. Bind the API to localhost. TLS terminates at Caddy or Nginx.


services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [internal]

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
    volumes:
      - ./data/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [internal]

  api:
    image: ghcr.io/nexuscore/nexuscron-api:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"
    networks: [internal, edge]
    read_only: true
    tmpfs: ["/tmp"]
    security_opt:
      - no-new-privileges:true

  worker:
    image: ghcr.io/nexuscore/nexuscron-worker:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      redis:
        condition: service_healthy
      postgres:
        condition: service_healthy
    deploy:
      replicas: 2
    networks: [internal]
    read_only: true
    tmpfs: ["/tmp"]
    security_opt:
      - no-new-privileges:true
    cap_drop: ["ALL"]

networks:
  internal:
    internal: true
  edge: {}


docker compose up -d
docker compose ps


4. Caddy


cron.example.com {
  encode gzip
  reverse_proxy 127.0.0.1:8080
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options nosniff
    X-Frame-Options DENY
    Referrer-Policy no-referrer
    -Server
  }
  request_body {
    max_size 1MB
  }
}


5. Map jobs and cut over


EasyCron

NexusCron

cron_expression

cron_expr

url

target_url

http_method

http_method

http_headers

headers JSON

http_message

body

cron_job_name

name

timezone

timezone

timeout

timeout_seconds

status

enabled


curl -sS -X POST https://cron.example.com/v1/jobs \
  -H "Authorization: Bearer $NEXUSCRON_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-webhook",
    "cron_expr": "0 3 * * *",
    "timezone": "Europe/Madrid",
    "http_method": "POST",
    "target_url": "https://app.example.com/hooks/nightly",
    "headers": {"X-Cron-Secret": "replace-me"},
    "timeout_seconds": 30,
    "max_retries": 3,
    "enabled": true
  }'


Cutover without double-firing:


  1. Stack healthy, TLS valid, /healthz 200

  2. Pause jobs in EasyCron (do not delete)

  3. Enable matching jobs in NexusCron

  4. Watch one full cycle

  5. Manually run anything due during the pause

  6. Disable EasyCron jobs

  7. Snapshot Postgres off-host


Set NEXUSCRON_ALLOW_PRIVATE_TARGETS=false so jobs cannot hit RFC1918 or metadata IPs.


docker compose exec -T postgres pg_dump -U nexcron nexcron | gzip > /opt/nexuscron/backups/nc-$(date +%F).sql.gz


Get NexusCron


Lifetime license, source, Docker. Store:


Comment with the EasyCron job id, the NexusCron job id, and a redacted /health payload. Do not paste signing secrets.

Profile picture
Alfredo BustamanteProfile picture@alfredobustamante33·4d
Pinned post

Getting started: self-host NexusCore on a VPS

Getting started: self-host NexusCore on a single VPS with Docker


This is the public deploy path for NexusCron and Webhook Router on one VPS. By the end both services sit behind Caddy with TLS, persistent data, and healthchecks.


Licenses and downloads live on the store:


If you are moving off EasyCron, read Replacing EasyCron with self-hosted NexusCron after this. For signatures, queues, and retries, read Self-hosted webhook processing.


What you need


  • A VPS with a public IP (2 vCPU / 2 GB RAM is enough to start)

  • Ubuntu 22.04 or 24.04 (Debian works the same)

  • Docker Engine 24+ and Docker Compose v2 (docker compose)

  • A domain with A records at the VPS (example: cron.example.com and hooks.example.com)

  • SSH as a user with sudo

  • Ports 80 and 443 free (do not publish 8080/8081)


docker --version
docker compose version


1. Prepare the VPS


sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg ufw

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker


Firewall (SSH + HTTP/HTTPS only):


sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status


2. Project layout


sudo mkdir -p /opt/nexuscore/{data/cron,data/router,caddy}
sudo chown -R $USER:$USER /opt/nexuscore
cd /opt/nexuscore


Create .env (chmod 600, never commit it):


TZ=Europe/Madrid
NEXUS_DOMAIN_CRON=cron.example.com
NEXUS_DOMAIN_ROUTER=hooks.example.com

NEXUSCRON_PORT=8080
NEXUSCRON_DATA_DIR=/data
NEXUSCRON_SECRET=change-me-to-a-long-random-string
NEXUSCRON_DATABASE_URL=sqlite:///data/nexuscron.db
NEXUSCRON_LOG_LEVEL=info

WEBHOOK_ROUTER_PORT=8081
WEBHOOK_ROUTER_DATA_DIR=/data
WEBHOOK_ROUTER_SECRET=change-me-to-another-long-random-string
WEBHOOK_ROUTER_DATABASE_URL=sqlite:///data/router.db
WEBHOOK_ROUTER_LOG_LEVEL=info


openssl rand -hex 32


Use a different value for each secret.


3. Docker Compose


Save as /opt/nexuscore/docker-compose.yml. Swap image names for the release you downloaded after purchase.


services:
  nexuscron:
    image: ghcr.io/nexuscore/nexuscron:latest
    restart: unless-stopped
    env_file: .env
    environment:
      TZ: ${TZ}
      PORT: ${NEXUSCRON_PORT}
      DATA_DIR: ${NEXUSCRON_DATA_DIR}
      SECRET: ${NEXUSCRON_SECRET}
      DATABASE_URL: ${NEXUSCRON_DATABASE_URL}
      LOG_LEVEL: ${NEXUSCRON_LOG_LEVEL}
    volumes:
      - ./data/cron:/data
    expose:
      - "8080"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s
    networks:
      - edge

  webhook-router:
    image: ghcr.io/nexuscore/webhook-router:latest
    restart: unless-stopped
    env_file: .env
    environment:
      TZ: ${TZ}
      PORT: ${WEBHOOK_ROUTER_PORT}
      DATA_DIR: ${WEBHOOK_ROUTER_DATA_DIR}
      SECRET: ${WEBHOOK_ROUTER_SECRET}
      DATABASE_URL: ${WEBHOOK_ROUTER_DATABASE_URL}
      LOG_LEVEL: ${WEBHOOK_ROUTER_LOG_LEVEL}
    volumes:
      - ./data/router:/data
    expose:
      - "8081"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s
    networks:
      - edge

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      nexuscron:
        condition: service_healthy
      webhook-router:
        condition: service_healthy
    networks:
      - edge

networks:
  edge:

volumes:
  caddy_data:
  caddy_config:


Pin tags (or digests) once you are in production. Keep restart: unless-stopped.


4. Caddy


/opt/nexuscore/caddy/Caddyfile:


cron.example.com {
  encode gzip
  reverse_proxy nexuscron:8080
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options nosniff
  }
}

hooks.example.com {
  encode gzip
  reverse_proxy webhook-router:8081
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options nosniff
  }
}


5. Start and verify


cd /opt/nexuscore
docker compose pull
docker compose up -d
docker compose ps


curl -fsS https://cron.example.com/health
curl -fsS https://hooks.example.com/health


Expect HTTP 200. If a container is unhealthy, docker compose logs <service> first.


6. First-run checks


  1. Open NexusCron at https://cron.example.com and finish admin setup.

  2. Create a test job that hits https://hooks.example.com/health every minute.

  3. Create an inbound Webhook Router route and send:


curl -i -X POST "https://hooks.example.com/in/<your-route-id>" \
  -H "Content-Type: application/json" \
  -d '{"event":"ping","ok":true}'


7. Backups


sudo tar -C /opt/nexuscore -czf ~/nexuscore-$(date +%F).tar.gz data .env docker-compose.yml caddy


Keep at least one copy off the VPS.


Hardening


  • Do not publish 8080/8081 on the host

  • SSH keys only

  • Rotate secrets if they leak

  • Pin image versions in production


Get the binaries


NexusCron and Webhook Router are lifetime licenses. Start at or


Stuck on DNS, TLS, or Compose? Comment with docker compose ps and the last 50 log lines. Do not paste secrets.

Profile picture
Alfredo BustamanteProfile picture@alfredobustamante33·4d
Pinned post

Self-hosted webhook processing with Docker Compose

Self-hosted webhook processing with Docker Compose


Engineering reference for running webhook processing on servers you control. Same shape later if you add cron or pipeline workers next to the router.


Webhook Router license:


What you are running


A webhook processor should:


  1. Accept inbound HTTP callbacks

  2. Authenticate the request (signature, timestamp, replay window)

  3. Enqueue work so the HTTP handler returns quickly

  4. Execute handlers with retries, backoff, and dead-lettering

  5. Record delivery status so you can replay failures


Do not process heavy work inside the request. Acknowledge first, then consume from a queue.


Prerequisites


  • Linux server you control

  • Docker Engine 24+ and Docker Compose v2

  • Reverse proxy that terminates TLS (Caddy, Traefik, or nginx)

  • Persistent disk for Postgres (SQLite only for a lab)

  • Hostname such as hooks.internal.example.com

  • Secrets in .env (chmod 600): signing secrets, database URL, admin token


Capacity baseline: 1 vCPU / 1 GB RAM for tens of events per second with Postgres + Redis.


Compose layout


services:
  proxy:
    image: caddy:2
    ports:
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    depends_on:
      - router

  router:
    image: ghcr.io/your-org/webhook-router:stable
    env_file: .env
    environment:
      DATABASE_URL: postgres://router:${DB_PASSWORD}@db:5432/router
      REDIS_URL: redis://redis:6379/0
      LISTEN_ADDR: 0.0.0.0:8080
    expose:
      - "8080"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true

  worker:
    image: ghcr.io/your-org/webhook-router:stable
    command: ["worker"]
    env_file: .env
    environment:
      DATABASE_URL: postgres://router:${DB_PASSWORD}@db:5432/router
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    deploy:
      replicas: 2

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: router
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: router
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U router"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:
  caddy_data:


Pin images by digest in production. Do not publish Postgres or Redis.


HTTP contract


Public route: POST /webhooks/{provider}.


  1. Read raw body. Verify HMAC against the raw bytes.

  2. Reject timestamps outside ±5 minutes.

  3. Deduplicate on (provider, event_id).

  4. Insert an inbox row and publish to the queue.

  5. Return 202 Accepted (or 200 if a vendor requires it).


Failed verification: 401/403, no enqueue. Log the reason without logging secrets.


Errors


Class

Examples

Action

Permanent

Bad signature, unknown tenant

Do not retry. Dead letter.

Transient

Downstream 5xx, timeout

Exponential backoff + jitter.

Poison

Panic, infinite loop

Max attempts, then dead letter. Alert.


Handlers must be safe to run twice. Key writes on event_id.


Security


  • Public: 443 to the proxy only

  • Constant-time signature compare

  • Two active signing keys during rotation

  • Encrypt bodies at rest if they contain PII

  • Non-root image, read_only, drop capabilities

  • Replay APIs behind TLS and an admin token


Checklist


  1. Secrets in .env, mode 600, not committed

  2. docker compose config

  3. Run migrations

  4. docker compose up -d

  5. Hit /readyz

  6. Send a signed test event. Confirm 202, then worker success

  7. Fail a signature on purpose. Confirm no enqueue

  8. Kill a worker mid-job. Confirm retry and idempotent result


Do not process vendor work in the HTTP thread. Do not expose Postgres on 0.0.0.0. Do not store secrets in Compose YAML.


Get Webhook Router


Lifetime license on the store:


Questions belong in comments. Include event_id, provider, attempt count, and a redacted error. Do not paste signing secrets.

Profile picture
Alfredo BustamanteProfile picture@alfredobustamante33·Oct 5
Pinned post

Start here: own your cron, webhooks, and automation

Start here


NexusCore is a small software studio in Madrid. We build self-hosted tools so you can own cron jobs, webhooks, and glue scripts instead of renting them.


This public feed is the getting-started layer. Read it before you buy. Ask in the comments if a step is unclear.


What we ship


  • NexusCron — task scheduler you buy once and run on your own machines

  • Webhook Router — intercept, transform, and route webhooks without another subscription

  • NexusPipeline / NexusAdmin — job execution and an admin dashboard on the same stack

  • LogStream, EnvSync, API Proxy — logs, encrypted config sync, and resilient outbound APIs

  • Ops Microservices — LogStream + EnvSync + API Proxy on one lifetime license

  • Complete Stack — every tool on one license


Store:

Discover:


Read these three next


  1. Getting started: self-host NexusCore on a VPS — Docker Compose, Caddy, healthchecks

  2. Replacing EasyCron with self-hosted NexusCron — export jobs, cut over without double-firing

  3. Self-hosted webhook processing — signatures, queues, retries, Compose hardening


If you want a zero-cost first click


Grab the free NexusCore product on the store. It is the workspace entry. Paid tools add the binaries and source after checkout.


How buying works


  1. Open Products and pick the tool or bundle

  2. After checkout, files show up in the member sidebar

  3. Deploy on a VPS you control. We do not host your jobs.


Questions about deploy, not billing, belong in the comments on the matching guide.