Skip to content

aloli-crystal/deploy

Repository files navigation

deploy

Crystal shard for deploying Marten and Kemal applications on FreeBSD servers. Capistrano style: releases/, current/, shared/. Dynamically generates rc.d and nginx.conf scripts.

Features

  • SSH deployment from your local terminal — no server-side dependencies

  • Native support for Marten and Kemal via the framework field in deploy.yml

  • Capistrano structure: releases/, current/, shared/

  • Dynamic generation of FreeBSD rc.d scripts (with daemon(8), auto-restart and REQUIRE: postgresql)

  • Dynamic generation of nginx.conf (FreeBSD pkg or Passenger in /opt) with custom error pages (502/503/504)

  • Interactive dialogue to build .env during initialization

  • Automatic creation of PostgreSQL/MariaDB databases and users

  • Automatic CNAME creation via OVH or Gandi REST API (keys read from .env)

  • One-command rollback (with Marten migrations re-application)

  • Keeps the last N releases (configurable)

  • Crystal compilation inside a tmux session (resilient to SSH drops)

  • Automatic generation of GitHub Actions workflow (CI/CD) via generate-ci

  • Graceful shutdown (zero-downtime): SIGTERM + waits for requests to finish before switching

  • Automatic environment detection from the current git branch

  • Automatic crontab: idempotent installation from config/cron/crontab

  • Parallelized deployment: migrations, assets and crontab run during compilation

Installation

Add the shard to your shard.yml:

dependencies:
  deploy:
    github: aloli-crystal/deploy
    version: "~> 0.1"

targets:
  deploy:
    main: lib/deploy/src/cli.cr

Then compile the deploy binary:

shards install
shards build deploy
Note
The deploy binary must be compiled in your project (not in the shard). It reads config/deploy.yml from the current directory.

Configuration

Copy the example file to your project:

cp lib/deploy/examples/marten/config/deploy.yml config/deploy.yml

Then adapt config/deploy.yml:

app_name: my-app                               # (1)
repo_url: git@github.com:user/my-app.git
crystal_main: src/server.cr
keep_releases: 10
framework: marten                              # (2)
database: postgresql                           # (3)

dns:                                           # (4)
  registrar: ovh
  zone: example.app

env_vars:
  required:
    - key: SECRET_KEY
      secret: true
      generate: hex64

environments:
  staging:
    branch: staging
    host: staging.example.com
    user: deploy
    app_url: https://staging.my-app.example.app
    dns_subdomain: staging.my-app              # (5)
    dns_target: staging.example.com.

  production:
    branch: production
    host: prod.example.com
    user: deploy
    app_url: https://my-app.example.app
  1. Used to name directories, services, and binaries: my-app—​staging

  2. Accepted values: marten or kemal. Adapts NGINX, migrations, CI, and .env variables

  3. Database adapter: postgresql, mariadb, sqlite or none

  4. DNS registrar (optional): ovh or gandi

  5. Optional — requires registrar API keys in your local .env

Commands

bin/deploy init --staging       # Server initialization (once)
bin/deploy deploy --staging     # Deploy a new release
bin/deploy deploy --production  # Deploy to production
bin/deploy rollback --staging   # Rollback to previous release
bin/deploy status --staging     # Active version and available releases
bin/deploy generate-ci          # Generate GitHub Actions workflow
bin/deploy dns-setup --staging  # Configure DNS keys (OVH / Gandi)

Automatic environment detection

Without --<env>, the current git branch is used to determine the environment:

git checkout staging
bin/deploy deploy               # → deploys staging automatically

git checkout production
bin/deploy deploy               # → deploys production automatically

Matching is done via the branch field of each environment in config/deploy.yml.

Prefix shortcuts

Environment names can be abbreviated:

bin/deploy deploy --stag        # → staging
bin/deploy deploy --prod        # → production

Marten vs Kemal support

The framework field in config/deploy.yml automatically adapts behavior:

Behavior framework: kemal framework: marten

NGINX .env variables

APP_URL + UNIX_SOCKET

MARTEN_ALLOWED_HOSTS + MARTEN_SOCKET

NGINX Assets

/css/, /js/, /images/, /vendor/

/assets/public/assets/

Migrations

db/schema_pg.sql at init

bin/marten migrate before each activation

Rollback migrations

Not applicable

bin/marten migrate on the previous release

CI Tests

DATABASE_URL + schema_pg.sql

DB_* + MARTEN_ENV=test + marten migrate

Seed

bin/marten seed (if command present)

Crontab

sed substitution + crontab(1)

bin/marten install_cron (idempotent)

Deployment flow

Deployment is optimized to minimize downtime. Steps using bin/marten run in parallel with the application binary compilation:

clone_repo
link_shared
shards_prepare              <- bin/marten available (~15s)
compile_start               <- crystal build --release in background (~200s)
  |-- run_migrations        |
  |-- run_seed              | parallel with compilation
  |-- collect_assets        |
  |-- install_crontab       |
compile_wait                <- synchronization
activate_release            <- ln -sf -> zero downtime
init_rcd
generate_env_exports
generate_wrapper
start_service
reload_nginx
cleanup_releases

Automatic crontab

If the project contains config/cron/crontab, it is automatically installed during deployment.

Available template variables

Variable Description Example

{{APP_HOME}}

Application home directory

/home/my-app—​staging

{{APP_FULL_NAME}}

Full name app—​env

my-app—​staging

{{MARTEN_ENV}}

Marten environment

staging

Example config/cron/crontab

# Daily recap at 7am
0 7 * * * cd {{APP_HOME}}/current && MARTEN_ENV={{MARTEN_ENV}} ./bin/{{APP_FULL_NAME}} recap_quotidien >> {{APP_HOME}}/shared/log/cron.log 2>&1

Idempotence

For Marten projects, bin/marten install_cron compares the current crontab with the new content and only reinstalls if different. If config/cron/crontab does not exist, an informational message is displayed and deployment continues.

Graceful shutdown (zero-downtime)

During each deployment, the script sends SIGTERM to the running process and waits for it to finish properly before switching to the new release. In-flight HTTP requests are not interrupted.

The wait timeout is configurable via the GRACEFUL_TIMEOUT environment variable (default: 30 seconds). Beyond that, a SIGKILL is sent as a last resort.

Kemal:

Signal::TERM.trap do
  STDERR.puts "[SHUTDOWN] SIGTERM received — graceful shutdown in progress..."
  spawn { Kemal.stop rescue nil }
end

Marten: graceful shutdown is natively handled by Marten::Server.

Rollback

bin/deploy rollback --production

For Marten, migrations are re-applied to the previous release after the rollback.

Server structure

/home/my-app--staging/
+-- releases/
|   +-- 20260410_143022/          <- timestamped release
|   |   +-- bin/my-app--staging
|   |   +-- ...
|   +-- 20260409_091500/
+-- current -> releases/20260410_143022/
+-- shared/
    +-- .env                      <- persistent across deploys
    +-- env_exports.sh            <- exports generated by Crystal
    +-- nginx.conf                <- generated by init
    +-- repo.git/                 <- bare repo (incremental fetch)
    +-- public/
    |   +-- erreur-indisponible.html
    +-- log/
        +-- cron.log

/tmp/.my-app--staging.pid         <- pidfile
/tmp/.my-app--staging.sock        <- Unix socket (deploy:www 660)

/usr/local/etc/rc.d/my_app__staging
/usr/local/bin/my-app--staging -> current/bin/...

NGINX

The shared/nginx.conf file is generated during init. It is automatically linked according to the detected mode:

Mode Link created Directive in main nginx.conf

Passenger (/opt/websites/)

/opt/websites/my-app—​staging.conf

include /opt/websites/*.conf;

FreeBSD pkg

sites-enabled/my-app—​staging

include sites-enabled/*;

The Unix socket is created with deploy:www 660 permissions so that NGINX can access it.

Configuration files management

File Role Versioned

config/deploy.yml

Single source of truth for infrastructure. Environments, hosts, branches, URLs, framework.

Yes

Local .env

Developer secrets. DNS API keys (OVH/Gandi) for administration from local machine.

No

shared/.env (server)

Application secrets. Built during init. DB credentials, secret keys, SMTP.

No

Warning

Never duplicate information from config/deploy.yml in your local .env.

DNS configuration (optional)

Supported registrars: OVH and Gandi.

bin/deploy dns-setup --staging   # Configure registrar API keys

CNAME records are automatically created during init.

Optional DNS fields per environment

environments:
  staging:
    dns_subdomain: staging.my-app     # subdomain (default: first label of app_url)
    dns_target: server.example.com.   # CNAME target (default: host with trailing dot)

GitHub Actions (CI/CD)

bin/deploy generate-ci

Generates .github/workflows/deploy.yml with:

  1. Test job: PostgreSQL, Crystal, compilation + specs

  2. Deploy job: automatic deployment on push (staging / production)

  3. GitHub Issues notifications on failure

Setup

  1. Generate a dedicated SSH key and authorize it on the server

  2. Add the SSH_PRIVATE_KEY secret in the GitHub repository settings

  3. Run bin/deploy generate-ci and commit the workflow

Environment variables

Automatically managed (skipped by default)

These variables are injected by deploy and are never requested during init:

  • MARTEN_ENV, MARTEN_ALLOWED_HOSTS, MARTEN_SOCKET

  • APP_HOST, APP_PORT, PORT

  • DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_NAME_TEST

  • DATABASE_URL

Custom variables

Declared in env_vars.required of config/deploy.yml:

env_vars:
  required:
    - key: SECRET_KEY
      secret: true
      generate: hex64     # auto-generated if empty
    - key: STRIPE_SECRET_KEY
      secret: true

License

MIT — see LICENSE

About

Shard Crystal pour le déploiement d'applications Marteen ou Kemal sur FreeBSD (style Capistrano)

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors