crystal-deploy
= 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
frameworkfield indeploy.yml - Capistrano structure:
releases/,current/,shared/ - Dynamic generation of FreeBSD
rc.dscripts (withdaemon(8), auto-restart andREQUIRE: postgresql) - Dynamic generation of
nginx.conf(FreeBSD pkg or Passenger in/opt) with custom error pages (502/503/504) - Interactive dialogue to build
.envduring 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
tmuxsession (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_SOCKETAPP_HOST,APP_PORT,PORTDB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME,DB_NAME_TESTDATABASE_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]
crystal-deploy
- 0
- 0
- 0
- 0
- 0
- 3 days ago
- March 4, 2026
MIT License
Sat, 11 Apr 2026 21:45:46 GMT