crystal-deploy

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

= crystal-deploy :toc: left :toc-title: Table of Contents :toclevels: 3 :source-highlighter: rouge :icons: font

Crystal shard for deploying https://martenframework.com[Marten] and https://kemalcr.com[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:

[source,yaml]

dependencies: crystal-deploy: github: aloli-crystal/crystal-deploy branch: main

targets: deploy: main: lib/crystal-deploy/src/deploy.cr

Then compile the deploy binary:

[source,sh]

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:

[source,sh]

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

Then adapt config/deploy.yml:

[source,yaml]

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

[source,sh]

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:

[source,sh]

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:

[source,sh]

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

== Marten vs Kemal support

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

[cols="2,3,3"] |=== | 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:

[source]

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

[cols="1,2,2"] |=== | 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

[source]

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:

[source,crystal]

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

[source,sh]

bin/deploy rollback --production

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

== Server structure

[source]

/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:

[cols="1,2,2"] |=== | 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

[cols="2,3,1"] |=== | 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.

[source,sh]

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

CNAME records are automatically created during init.

=== Optional DNS fields per environment

[source,yaml]

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)

[source,sh]

bin/deploy generate-ci

Generates .github/workflows/deploy.yml with:

. Test job: PostgreSQL, Crystal, compilation + specs . Deploy job: automatic deployment on push (staging / production) . GitHub Issues notifications on failure

=== Setup

. Generate a dedicated SSH key and authorize it on the server . Add the SSH_PRIVATE_KEY secret in the GitHub repository settings . Run bin/deploy generate-ci and commit the workflow

== Environment variables

=== Automatically managed (skipped by default)

These variables are injected by crystal-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:

[source,yaml]

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

== License

MIT — see link:LICENSE[LICENSE]

Repository

crystal-deploy

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • 3 days ago
  • March 4, 2026
License

MIT License

Links
Synced at

Sat, 11 Apr 2026 21:45:46 GMT

Languages