-
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
Add the shard to your shard.yml:
dependencies:
deploy:
github: aloli-crystal/deploy
version: "~> 0.1"
targets:
deploy:
main: lib/deploy/src/cli.crThen 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.
|
Copy the example file to your project:
cp lib/deploy/examples/marten/config/deploy.yml config/deploy.ymlThen 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-
Used to name directories, services, and binaries:
my-app—staging -
Accepted values:
martenorkemal. Adapts NGINX, migrations, CI, and.envvariables -
Database adapter:
postgresql,mariadb,sqliteornone -
DNS registrar (optional):
ovhorgandi -
Optional — requires registrar API keys in your local
.env
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)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 automaticallyMatching is done via the branch field of each environment in config/deploy.yml.
The framework field in config/deploy.yml automatically adapts behavior:
| Behavior | framework: kemal |
framework: marten |
|---|---|---|
NGINX |
|
|
NGINX Assets |
|
|
Migrations |
|
|
Rollback migrations |
Not applicable |
|
CI Tests |
|
|
Seed |
— |
|
Crontab |
|
|
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_releasesIf the project contains config/cron/crontab, it is automatically installed during deployment.
| Variable | Description | Example |
|---|---|---|
|
Application home directory |
|
|
Full name app—env |
|
|
Marten environment |
|
# 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>&1During 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 }
endMarten: graceful shutdown is natively handled by Marten::Server.
bin/deploy rollback --productionFor Marten, migrations are re-applied to the previous release after the rollback.
/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/...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 ( |
|
|
FreeBSD pkg |
|
|
The Unix socket is created with deploy:www 660 permissions so that NGINX can access it.
| File | Role | Versioned |
|---|---|---|
|
Single source of truth for infrastructure. Environments, hosts, branches, URLs, framework. |
Yes |
Local |
Developer secrets. DNS API keys (OVH/Gandi) for administration from local machine. |
No |
|
Application secrets. Built during |
No |
|
Warning
|
Never duplicate information from |
Supported registrars: OVH and Gandi.
bin/deploy dns-setup --staging # Configure registrar API keysCNAME records are automatically created during init.
bin/deploy generate-ciGenerates .github/workflows/deploy.yml with:
-
Test job: PostgreSQL, Crystal, compilation + specs
-
Deploy job: automatic deployment on push (staging / production)
-
GitHub Issues notifications on failure
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
MIT — see LICENSE