Skip to content github facebook twitter

Table of Contents

  1. Getting Started
  2. Routes
  3. HTTP Parameters
  4. HTTP Request / Response Context
  5. Views / Templates
  6. Filters
  7. Helpers
  8. Middleware
  9. File Upload
  10. Sessions
  11. WebSockets
  12. Testing
  13. Static Files
  14. Configuration
  15. CLI
  16. SSL
  17. Security
  18. Deployment

Getting Started

This guide assumes that you already have Crystal installed. If not, check out the Crystal installation methods and come back when you’re done.

Installing Kemal

First you need to create your application:

crystal init app your_app cd your_app 

Then add kemal to the shard.yml file as a dependency.

dependencies: kemal: github: kemalcr/kemal 

Finally run shards to get the dependencies:

shards install 

You should see something like this:

$ shards install Updating https://github.com/kemalcr/kemal.git Installing kemal (1.10.0) 

That’s it! You’re now ready to use Kemal in your application.

Using Kemal

You can do awesome stuff with Kemal. Let’s start with a simple example. Just change the content of src/your_app.cr to:

require "kemal" get "/" do "Hello World!" end Kemal.run 

Running Kemal

Starting your application is easy. Simply run:

crystal run src/your_app.cr 

If everything goes well, you should see a message saying that Kemal is running. If you are using Windows, use http://localhost:3000 or http://127.0.0.1:3000 instead of http://0.0.0.0:3000.

[development] Kemal is ready to lead at http://0.0.0.0:3000 2015-12-01 13:47:48 UTC 200 GET / 666µs 

Congratulations on your first Kemal application! This is just the beginning. Keep reading to learn how to do more with Kemal.

Routes

You can handle HTTP methods as easy as writing method names and the route with a code block. Kemal will handle all the hard work.

# GET - Retrieve data, list resources, show pages # Use for: Reading data, displaying pages, listing items get "/" do # Example: Show homepage, list users, display a blog post "Hello World!" end # POST - Create new resources # Use for: Creating new records, form submissions, user registration post "/" do # Example: Create new user, submit contact form, add item to cart end # PUT - Replace entire resource # Use for: Complete resource updates, replacing all fields put "/" do # Example: Update entire user profile, replace configuration end # PATCH - Partially update resource # Use for: Updating specific fields without replacing the entire resource patch "/" do # Example: Update only user's email, change post status end # DELETE - Remove resources # Use for: Deleting records, removing items, user logout delete "/" do # Example: Delete user account, remove blog post, clear cache end 

Any string returned from a route will output to the browser. Routes are matched in the order they are defined. The first route that matches the request is invoked.

Kemal::Router (Modular Routing)

Kemal provides a modular Kemal::Router for organizing routes into namespaces, with scoped filters and WebSocket support. The router can be mounted at any path while keeping the existing DSL fully compatible.

require "kemal" api = Kemal::Router.new api.namespace "/users" do get "/" do |env| env.json({users: ["alice", "bob"]}) end get "/:id" do |env| env.text "user #{env.params.url["id"]}" end end mount "/api/v1", api Kemal.run 

In this example, the routes are available at /api/v1/users and /api/v1/users/:id. You can define filters, WebSocket handlers, and additional namespaces within a router. Filters defined inside a namespace are isolated to that router’s routes, while global wildcard filters always execute.

HTTP Parameters

When passing data through an HTTP request, you will often need to use query parameters, or post parameters depending on which HTTP method you’re using.

URL Parameters

Kemal allows you to use variables in your route path as placeholders for passing data. To access URL parameters, you use env.params.url.

# Matches /hello/kemal get "/hello/:name" do |env| name = env.params.url["name"] "Hello back to #{name}" end # Matches /users/1 get "/users/:id" do |env| id = env.params.url["id"] "Found user #{id}" end # Matches /dir/and/anything/after get "/dir/*all" do |env| all = env.params.url["all"] "Found path #{all}" end 

Query Parameters

To access query parameters, you use env.params.query.

# Matches /resize?width=200&height=200 get "/resize" do |env| width = env.params.query["width"] height = env.params.query["height"] end 

POST / Form Parameters

Kemal has a few options for accessing post parameters. You can easily access JSON payload from the parameters, or through the standard post body.

For JSON parameters, use env.params.json. For body parameters, use env.params.body.

# The request content type needs to be application/json # The payload # {"name": "Serdar", "likes": ["Ruby", "Crystal"]} post "/json_params" do |env| name = env.params.json["name"].as(String) likes = env.params.json["likes"].as(Array) "#{name} likes #{likes.join(",")}" end # Using a standard post body # name=Serdar&likes=Ruby&likes=Crystal post "/body_params" do |env| name = env.params.body["name"].as(String) likes = env.params.body["likes"].as(Array) "#{name} likes #{likes.join(",")}" end 

NOTE: For Array or Hash like parameters, Kemal will group like keys for you. Alternatively, you can use the square bracket notation likes[]=ruby&likes[]=crystal. Be sure to access the param name exactly how it was passed. (i.e. env.params.body["likes[]"]).

Override Method (PUT, PATCH, DELETE from Forms)

HTML forms only support GET and POST methods. When you need to use PUT, PATCH, or DELETE in RESTful applications, Kemal’s OverrideMethodHandler middleware lets you simulate these methods. It reads the _method magic parameter from a POST request body and rewrites the request to the corresponding HTTP method.

Important: This middleware is not included in Kemal’s default handlers. You must add it explicitly to your application:

require "kemal" # Add OverrideMethodHandler to the handler chain add_handler Kemal::OverrideMethodHandler::INSTANCE put "/users/:id" do |env| id = env.params.url["id"] # User update logic "User #{id} updated" end patch "/users/:id" do |env| id = env.params.url["id"] # Partial update logic "User #{id} partially updated" end delete "/users/:id" do |env| id = env.params.url["id"] # User deletion logic "User #{id} deleted" end Kemal.run 

In your HTML form, add the _method parameter as a hidden field:

<!-- PUT - Replace entire resource --> <form action="/users/1" method="post"> <input type="hidden" name="_method" value="PUT"> <input type="text" name="name" value="Kemal"> <button type="submit">Update</button> </form> <!-- PATCH - Partial update --> <form action="/users/1" method="post"> <input type="hidden" name="_method" value="PATCH"> <input type="text" name="email" placeholder="New email"> <button type="submit">Update Email</button> </form> <!-- DELETE - Remove resource --> <form action="/users/1" method="post"> <input type="hidden" name="_method" value="DELETE"> <button type="submit">Delete</button> </form> 

Allowed methods: PUT, PATCH, DELETE

Note: This middleware consumes params.body to read the _method parameter. You can ignore this parameter when accessing your form data.

HTTP Request / Response Context

Accessing the HTTP request/response context (query parameters, body, content_type, headers, status_code) is super easy. You can use the context returned from the block:

# Matches /hello/kemal get "/hello/:name" do |env| name = env.params.url["name"] "Hello back to #{name}" end # Matches /resize?width=200&height=200 get "/resize" do |env| width = env.params.query["width"] height = env.params.query["height"] end # Easily access JSON payload from the parameters. # The request content type needs to be application/json # The payload # {"name": "Serdar", "likes": ["Ruby", "Crystal"]} post "/json_params" do |env| name = env.params.json["name"].as(String) likes = env.params.json["likes"].as(Array) "#{name} likes #{likes.join(',')}" end # Response helpers: chainable JSON, HTML, text, XML with HTTP::Status support get "/users" do |env| env.json({users: ["alice", "bob"]}) end post "/users" do |env| env.status(:created).json({id: 1, created: true}) end get "/admin" do |env| halt env.status(403).html("<h1>Forbidden</h1>") end get "/api/users" do |env| env.json({data: ["alice", "bob"]}, content_type: "application/vnd.api+json") end # Manual content type (alternative to helpers) get "/user.json" do |env| user = {name: "Kemal", language: "Crystal"}.to_json env.response.content_type = "application/json" user end # Add headers to your response get "/headers" do |env| env.response.headers["Accept-Language"] = "tr" env.response.headers["Authorization"] = "Token 12345" end # Set response status code get "/status-code" do |env| env.response.status_code = 404 end 

Context Storage

Contexts are useful for sharing states between filters and middleware. You can use context to store some variables and access them later at some point. Each stored value only exists in the lifetime of request / response cycle.

before_get "/" do |env| env.set "is_kemal_cool", true end get "/" do |env| is_kemal_cool = env.get "is_kemal_cool" "Kemal cool = #{is_kemal_cool}" end 

This renders Kemal cool = true when a request is made to /.

If you prefer a safer version use env.get? which won’t raise when the key doesn’t exist and will return nil instead.

get "/" do |env| non_existent_key = env.get?("non_existent_key") # => nil end 

Context storage also supports custom types. You can register and use a custom type as the following:

class User property name : String def initialize(@name : String) end end add_context_storage_type(User) before "/" do |env| env.set "user", User.new("dummy-user") end get "/" do |env| user = env.get "user" end 

Be aware that you have to declare the custom type before trying to add with add_context_storage_type.

Request Properties

Some common request information is available at env.request.*:

Views / Templates

You can use ERB-like built-in ECR to render dynamic views.

get "/:name" do |env| name = env.params.url["name"] render "src/views/hello.ecr" end 

Your hello.ecr view should have the same context as the method.

Hello <%= name %> 

Using Layouts

You can use layouts in Kemal. You can do this by passing a second argument to the render method.

get "/:name" do render "src/views/subview.ecr", "src/views/layouts/layout.ecr" end 

In your layout file, you need to return the output of subview.ecr with the content variable (like yield in Rails).

<html> <head> <title>My Kemal Application</title> </head> <body> <%= content %> </body> </html> 

content_for and yield_content

You can capture blocks inside views to be rendered later during the request with the content_for helper. The most common use is to populate different parts of your layout from your view.

Usage

First, call content_for, generally from a view, to capture a block of markup with an identifier:

# index.ecr <% content_for "some_key" do %> <chunk of="html">...</chunk> <% end %> 

Then, call yield_content with that identifier, generally from a layout, to render the captured block:

# layout.ecr <%= yield_content "some_key" %> 

This is useful because some of your views may need specific JavaScript tags or stylesheets and you don’t want to use these tags in all of your pages. To solve this problem, you can use <%= yield_content "scripts_and_styles" %> in your layout.ecr, inside the <head> tag, and each view can call content_for with the appropriate set of tags that should be added to the layout.

Using Common Paths

Since Crystal does not allow using variables in macro literals, you need to generate another helper macro to make the code easier to read and write.

 macro my_renderer(filename) render "my/app/view/base/path/#{ {{filename}} }.ecr", "my/app/view/base/path/layouts/layout.ecr" end 

And now you can use your new renderer.

get "/:name" do my_renderer "subview" end 

Filters

Before filters are evaluated before each request within the same context as the routes. They can modify the request and response.

Important note: This should not be used by plugins/addons, instead they should do all their work in their own middleware.

Available filters:

The Filter middleware is lazily added as soon as a call to after_X or before_X is made. It will not even be instantiated unless a call to after_X or before_X is made.

When using before_all and after_all keep in mind that they will be evaluated in the following order:

before_all -> before_x -> X -> after_x -> after_all 

Simple before_get example

before_get "/foo" do |env| env.response.content_type = "application/json" end get "/foo" do |env| puts env.response.headers["Content-Type"] # => "application/json" {"name": "Kemal"}.to_json end 

Simple before_all example

before_all applies to all HTTP methods for the given path. Same as before_get but also runs for put, post, etc.:

before_all "/foo" do |env| puts "Setting response content type" env.response.content_type = "application/json" end get "/foo" do |env| puts env.response.headers["Content-Type"] # => "application/json" {"name": "Kemal"}.to_json end put "/foo" do |env| puts env.response.headers["Content-Type"] # => "application/json" {"name": "Kemal"}.to_json end post "/foo" do |env| puts env.response.headers["Content-Type"] # => "application/json" {"name": "Kemal"}.to_json end 

Multiple before_all

You can add many blocks to the same verb/path combination by calling it multiple times they will be called in the same order they were defined.

before_all do |env| raise "Unauthorized" unless authorized?(env) end before_all do |env| env.session = Session.new(env.cookies) end get "/foo" do |env| "foo" end 

Each time GET /foo (or any other route since we didn’t specify a route for these blocks) is called the first before_all will run and then the second will set the session.

Note: authorized? and Session.new are fictitious calls used to illustrate the example.

Helpers

Browser Redirect

Browser redirects are simple as well. Simply call env.redirect in the route’s corresponding block.

# Redirect browser get "/logout" do |env| # important stuff like clearing session etc. env.redirect "/login" # redirect to /login page end 

Note: For configuration options like logging and public folder settings, see the Configuration section.

Halt

Halt execution with the current context. Returns 200 and an empty response by default.

halt env, status_code: 403, response: "Forbidden" 

You can also halt from a chained response for concise API error handling:

get "/admin" do |env| halt env.status(403).html("<h1>Forbidden</h1>") end 

Note: halt can only be used inside routes.

Custom Errors

You can customize the built-in error pages or even add your own with error.

error 404 do "This is a customized 404 page." end error 403 do "Access Forbidden!" end 

To handle a custom error based on a raised exception, you pass the exception to error

get "/" do |env| if some_condition raise ValueError.new end {"message": "Hello Kemal"}.to_json end error ValueError do "Something has gone wrong" end 

NOTE Exception handlers are resolved based on definition order first, and inheritance order second. For example:

 class GrandParentException < Exception; end class ParentException < GrandParentException; end class ChildException < ParentException; end error GrandParentException do "Grandparent exception" end error ParentException do "Parent exception" end get "/" do raise ChildException.new() end 

Will resolve to the handler for GrandParentException rather than ParentException

Send File

Send a file with the given path and base the MIME type on the file extension or default to application/octet-stream.

send_file env, "./path/to/file.jpg" 

Optionally, you can override the MIME type:

send_file env, "./path/to/file.exe", "image/jpeg" 

For both examples, the file will be sent with the image/jpeg MIME type.

MIME type detection is based on the MIME registry from the Crystal standard library, which uses the OS-provided MIME database. If unavailable, it falls back to a basic type list (MIME::DEFAULT_TYPES).

You can extend the registered type list by calling MIME.register with an extension and its desired type:

MIME.register ".cr", "text/crystal" 

Security Notice:
When using send_file with dynamic file paths (such as those based on user input), always sanitize and validate the path to prevent directory traversal and unauthorized file access. Never pass unchecked user input directly to send_file.
For example, ensure the path is within an allowed directory and does not contain sequences like ../ that could escape the intended folder.
See kemalcr/kemal#718 for more details.

Middleware

Middleware, also known as Handlers, are the building blocks of Kemal. Middleware lets you separate application concerns into different layers.

Each middleware is supposed to have one responsibility. Take a look at Kemal’s built-in middleware to see what that means.

The use Keyword

Use the use keyword to register middleware globally or for specific paths. You can pass a single handler, an array of handlers, or insert at a specific position in the handler chain.

require "kemal" # Path-specific middlewares for /api routes use "/api", [CORSHandler.new, AuthHandler.new] get "/" do "Public home" end get "/api/users" do |env| env.json({users: ["alice", "bob"]}) end Kemal.run 

Global middleware runs for all routes; path-specific middleware runs only for routes matching the given path prefix.

Creating your own middleware

You can create your own middleware by inheriting from Kemal::Handler

class CustomHandler < Kemal::Handler def call(context) puts "Doing some custom stuff here" call_next context end end add_handler CustomHandler.new 

Conditional Middleware Execution

Kemal gives you access to two handy filters only and exclude. These can be used to process your custom middleware for only specific routes, or to exclude from specific routes.

class OnlyHandler < Kemal::Handler # Matches GET /specials and GET /deals only ["/specials", "/deals"] def call(env) # continue on to next handler unless the request matches the only filter return call_next(env) unless only_match?(env) puts "If the path is /specials or /deals, I will be doing some processing here." end end class PostOnlyHandler < Kemal::Handler # Matches POST /blogs only ["/blogs"], "POST" def call(env) # call_next is called for GET /blogs, but not POST /blogs return call_next(env) unless only_match?(env) puts "If the request is a POST to /blogs, I will do some processing here." end end 
class ExcludeHandler < Kemal::Handler # Matches GET / exclude ["/"] def call(env) return call_next(env) if exclude_match?(env) puts "If the path is not / I will be doing some processing here." end end class PostExcludeHandler < Kemal::Handler # Matches POST / exclude ["/"], "POST" def call(env) return call_next(env) if exclude_match?(env) puts "If the request is not a POST to /, I will do some processing here." end end 

Creating a custom Logger middleware

You can easily replace the built-in logger of Kemal. There’s only one requirement which is that your logger must inherit from Kemal::BaseLogHandler.

class MyCustomLogger < Kemal::BaseLogHandler # This is run for each request. You can access the request/response context with `context`. def call(context) puts "Custom logger is in action." # Be sure to `call_next`. call_next context end def write(message) end end 

You need to register your custom logger with logger config property.

require "kemal" Kemal.config.logger = MyCustomLogger.new 

That’s it!

Kemal Middleware

The Kemal organization has a variety of useful middleware.

File Upload

Kemal provides easy access to uploaded files through env.params.files. When a file is uploaded via a form, it’s automatically stored in a temporary location and accessible through the parameter name.

Basic File Upload

Here’s a simple example of handling file uploads:

post "/upload" do |env| # Get the uploaded file from the form field named "image" file = env.params.files["image"].tempfile # Create the destination path file_path = ::File.join [Kemal.config.public_folder, "uploads/", File.basename(file.path)] # Copy the uploaded file to the destination File.open(file_path, "w") do |f| IO.copy(file, f) end "Upload successful!" end 

Advanced File Upload with Validation

For production applications, you should validate uploaded files:

post "/upload" do |env| # Check if file was uploaded unless env.params.files.has_key?("image") halt env, status_code: 400, response: "No file uploaded" end uploaded_file = env.params.files["image"] # Validate file size (e.g., max 5MB) max_size = 5 * 1024 * 1024 if uploaded_file.size > max_size halt env, status_code: 400, response: "File too large" end # Validate file type by extension allowed_extensions = [".jpg", ".jpeg", ".png", ".gif"] file_extension = File.extname(uploaded_file.filename || "").downcase unless allowed_extensions.includes?(file_extension) halt env, status_code: 400, response: "Invalid file type" end # Generate a unique filename to prevent conflicts unique_filename = "#{Time.utc.to_unix}_#{uploaded_file.filename}" file_path = ::File.join [Kemal.config.public_folder, "uploads/", unique_filename] # Save the file File.open(file_path, "w") do |f| IO.copy(uploaded_file.tempfile, f) end "File uploaded successfully as: #{unique_filename}" end 

File Upload Properties

The uploaded file object has the following properties:

Multiple File Upload

Kemal also supports uploading multiple files using array notation in form field names:

post "/upload-multiple" do |env| uploaded_file_names = [] of String # Get all files from the images[] field if env.params.files.has_key?("images[]") # env.params.files["images[]"] returns an array of uploaded files env.params.files["images[]"].each do |uploaded_file| # Validate each file max_size = 5 * 1024 * 1024 if uploaded_file.size > max_size next # Skip files that are too large end # Validate file type allowed_extensions = [".jpg", ".jpeg", ".png", ".gif"] file_extension = File.extname(uploaded_file.filename || "").downcase unless allowed_extensions.includes?(file_extension) next # Skip invalid file types end # Generate unique filename unique_filename = "#{Time.utc.to_unix}_#{Random.rand(1000)}_#{uploaded_file.filename}" file_path = ::File.join [Kemal.config.public_folder, "uploads/", unique_filename] # Save the file File.open(file_path, "w") do |f| IO.copy(uploaded_file.tempfile, f) end uploaded_file_names << unique_filename end end if uploaded_file_names.empty? "No valid files were uploaded" else "Successfully uploaded #{uploaded_file_names.size} files: #{uploaded_file_names.join(", ")}" end end 

Testing File Upload

You can test single file uploads using curl:

curl -F "image=@/path/to/your/file.png" http://localhost:3000/upload 

For multiple file uploads:

curl -F "images[]=@/path/to/file1.png" -F "images[]=@/path/to/file2.jpg" http://localhost:3000/upload-multiple 

Sessions

Kemal supports Sessions with kemal-session.

# User Login / Logout Example require "kemal" require "kemal-session" # Session Configuration Kemal::Session.config.secret = "my-secret-key" # User login (create session) post "/login" do |env| username = env.params.body["username"]?.to_s # In a real app you would authenticate here env.session.string("username", username) env.session.bool("logged_in", true) "Welcome #{username}, you're now logged in." end # Protected route using session get "/profile" do |env| unless env.session.bool?("logged_in") env.response.status_code = 401 next "Please log in first" end username = env.session.string("username") "Hello #{username}!" end # User logout (destroy session) post "/logout" do |env| env.session.destroy "You have been logged out." end Kemal.run 

kemal-session has a generic API to multiple storage engines. The default storage engine is MemoryEngine which stores the sessions in process memory. You should only use MemoryEngine for development and testing purposes.

See kemal-session for usage and compatible storage engines.

Accessing the CSRF token

To access the CSRF token of the active session you can do the following in your form:

<input type="hidden" name="authenticity_token" value="<%= env.session.string("csrf") %>"> 

WebSockets

Using Websockets with Kemal is super easy!

You can create a WebSocket handler which matches the route of ws://host:port/route. You can create more than 1 websocket handler with different routes.

ws "/" do |socket| end ws "/route2" do |socket| end 

Let’s access the socket and create a simple echo server.

# Matches "/" ws "/" do |socket| # Send welcome message to the client socket.send "Hello from Kemal!" # Handle incoming message and echo back to the client socket.on_message do |message| socket.send "Echo back from server #{message}" end # Executes when the client is disconnected. You can do the cleaning up here. socket.on_close do puts "Closing socket" end end 

ws yields a second parameter which lets you access the HTTP::Server::Context which lets you use the underlying request and response.

ws "/" do |socket, context| headers = context.request.headers socket.send headers["Content-Type"]? end 

Accessing Dynamic Url Params

ws "/:id" do |socket, context| id = context.ws_route_lookup.params["id"] end 

Testing

You can test your Kemal application using spec-kemal.

Your Kemal application

# src/your-kemal-app.cr require "kemal" get "/" do "Hello World!" end Kemal.run 

First add spec-kemal to your shard.yml

name: your-kemal-app version: 0.1.0 dependencies: spec-kemal: github: kemalcr/spec-kemal kemal: github: kemalcr/kemal 

Install dependencies

shards install 

Require it before your files in your spec/spec_helper.cr

require "spec-kemal" require "../src/your-kemal-app" 

Now you can easily test your Kemal application in your specs. Create a file called spec/your-kemal-app_spec.cr:

require "./spec_helper" describe "Your::Kemal::App" do # You can use get,post,put,patch,delete to call the corresponding route. it "renders /" do get "/" response.body.should eq "Hello World!" end end 

Run the tests:

KEMAL_ENV=test crystal spec 

Static Files

Any files you add to the public directory will be served automatically by Kemal.

app/ src/ your_app.cr public/ js/ jquery.js your_app.js css/ your_app.css index.html 

For example, your index.html may look like this:

<html> <head> <script src="/js/jquery.js"></script> <script src="/js/your_app.js"></script> <link rel="stylesheet" href="/css/your_app.css"/> </head> <body> ... </body> </html> 

Kemal will serve the files in the public directory without having to write routes for them.

Note: For configuration options like changing the public folder, disabling static files, adding custom headers, or configuring gzip and directory listing, see the Static Files Configuration section.

Configuration

Kemal provides a powerful configuration system through Kemal.config that allows you to customize various aspects of your application. Here are all the available public configuration options:

Server Configuration

Host and Port

Configure the host address and port your application listens on:

Kemal.config.host_binding = "127.0.0.1" # Default: "0.0.0.0" Kemal.config.port = 8080 # Default: 3000 

You can also set these via command line flags:

./your_app --bind 127.0.0.1 --port 8080 

Max Request Body Size

Limit the maximum size of HTTP request bodies to prevent potential memory exhaustion or DoS attacks:

Kemal.config.max_request_body_size = 1024 * 1024 * 10 # 10 MB (in bytes) # Default: 8 MB 

When a request exceeds this limit, Kemal will reject it with a 413 Payload Too Large response. This is particularly useful for:

Example with different limits for different purposes:

# For API with JSON payloads Kemal.config.max_request_body_size = 1024 * 100 # 100 KB # For file upload applications Kemal.config.max_request_body_size = 1024 * 1024 * 50 # 50 MB # No limit (use with caution in production) Kemal.config.max_request_body_size = nil 

Note: Setting this value too low may prevent legitimate large requests from being processed. Choose a value that balances security with your application’s requirements.

Static Files Configuration

Public Folder

Set the directory for serving static files:

Kemal.config.public_folder = "./assets" # Default: "./public" 

Serve Static Files

Enable or disable static file serving:

Kemal.config.serve_static = false # Default: true 

You can also pass options for gzip compression and directory listing:

Kemal.config.serve_static = {"gzip" => true, "dir_listing" => false} 

By default Kemal gzips most files, skipping only very small files, or those which don’t benefit from gzipping. If you are running Kemal behind a proxy, you may wish to disable this feature.

Static Headers

Add custom headers to static files served by Kemal::StaticFileHandler. This is especially useful for CORS or caching:

static_headers do |response, filepath, filestat| if filepath =~ /\.html$/ response.headers.add("Access-Control-Allow-Origin", "*") end response.headers.add("Content-Size", filestat.size.to_s) end 

Logging Configuration

Enable/Disable Logging

Kemal enables logging by default. You can easily disable it:

Kemal.config.logging = false # Default: true 

You can add logging statements to your code:

Log.info { "Log message with or without embedded #{variables}" } 

Custom Logger

You can easily replace the built-in logger of Kemal. Your logger must inherit from Kemal::BaseLogHandler. See Creating a custom Logger middleware for the full implementation. Register it with:

Kemal.config.logger = MyCustomLogger.new 

SSL Configuration

Configure SSL/TLS for HTTPS:

Kemal.config.ssl = true Kemal.config.ssl_certificate_file = "/path/to/cert.pem" Kemal.config.ssl_key_file = "/path/to/key.pem" 

Or use command line flags:

./your_app --ssl --ssl-cert-file cert.pem --ssl-key-file key.pem 

Environment Configuration

Kemal respects the KEMAL_ENV environment variable and Kemal.config.env. It is set to development by default.

To change this value to production, for example, use:

$ export KEMAL_ENV=production 

If you prefer to do this from within your application, use:

Kemal.config.env = "production" 

When the KEMAL_ENV environment variable is not set to production, e.g. development, an exception page is rendered when an exception is raised which provides a lot of useful information for debugging. However, if the environment variable is set to production a standard error page is rendered (see source).

Note: KEMAL_ENV should always be set to production in a production environment for security reasons.

Error Handling

Powered By Header

Hide or customize the “X-Powered-By” header:

Kemal.config.powered_by_header = false # Disable header Kemal.config.powered_by_header = "MyApp" # Custom value # Default: "Kemal" 

Always Rescue

Control whether Kemal should rescue all exceptions:

Kemal.config.always_rescue = false # Default: true 

When set to false, exceptions will not be caught by Kemal’s exception handler and will propagate up.

Handler Configuration

Add Custom Handlers

Add custom middleware/handlers to your application:

Kemal.config.add_handler MyCustomHandler.new 

Handlers are added in the order they’re called and will be executed in that order for each request.

Extra Options

Store custom application-wide configuration:

Kemal.config.extra_options do |parser| parser.on("-c CONFIG", "--config CONFIG", "Load configuration from file") do |config_file| # Your custom logic here end end 

Server Instance Configuration

Customize HTTP Server

Access and configure the underlying HTTP::Server instance:

Kemal.config.server.not_nil!.bind_tcp "0.0.0.0", 3000, reuse_port: true 

Shutdown Timeout

Configure graceful shutdown timeout:

Kemal.config.shutdown_timeout = 10.seconds # Default: nil (no timeout) 

Complete Configuration Example

Here’s a comprehensive example showing multiple configuration options:

require "kemal" # Server settings Kemal.config.host_binding = "0.0.0.0" Kemal.config.port = 3000 Kemal.config.env = "production" Kemal.config.max_request_body_size = 1024 * 1024 * 10 # 10 MB limit # Static files Kemal.config.public_folder = "./public" Kemal.config.serve_static = {"gzip" => true, "dir_listing" => false} # Logging Kemal.config.logging = true # SSL Kemal.config.ssl = true Kemal.config.ssl_certificate_file = "./ssl/cert.pem" Kemal.config.ssl_key_file = "./ssl/key.pem" # Headers Kemal.config.powered_by_header = "MyApp/1.0" # Error handling Kemal.config.always_rescue = true # Add custom handler Kemal.config.add_handler MyAuthHandler.new # Your routes go here get "/" do "Hello World!" end Kemal.run 

Configuration Priority

Configuration values are resolved in the following order (highest to lowest priority):

  1. Command-line arguments (--port, --bind, etc.)
  2. Code configuration (Kemal.config.port = 3000)
  3. Environment variables (KEMAL_ENV)
  4. Default values

Helper Methods vs Config Methods

Kemal provides two equivalent ways to configure most options:

Helper methods (shorthand):

logging false public_folder "./assets" serve_static false 

Config object (explicit):

Kemal.config.logging = false Kemal.config.public_folder = "./assets" Kemal.config.serve_static = false 

Both approaches are valid and produce the same result. Use whichever style fits your preference.

CLI

A Kemal application accepts a few optional command-line flags:

Short flag Long flag Description
-b HOST --bind HOST Host to bind (default: 0.0.0.0)
-p PORT --port PORT Port to listen for connection (default: 3000)
-s --ssl Enables SSL
  --ssl-key-file FILE SSL key file
  --ssl-cert-file FILE SSL certificate file

Note: For detailed configuration options and programmatic configuration, see the Configuration section.

SSL

Kemal has built-in and easy to use SSL support. To start your Kemal app with SSL, build and run with the SSL flags:

crystal build --release src/your_app.cr ./your_app --ssl --ssl-key-file your_key_file --ssl-cert-file your_cert_file 

Security

Best practices for securing your Kemal application: resource limits, security headers (Helmet), rate limiting, and the Defense shard for throttling and blocking malicious requests.

Resource Limits

Set appropriate limits:

require "kemal" # Maximum request body size (10 MB) Kemal.config.max_request_body_size = 10 * 1024 * 1024 # Powered by header (hide for security) Kemal.config.powered_by_header = false # Always rescue in production Kemal.config.always_rescue = true 

Helmet

Helmet helps you secure your Kemal app by setting various HTTP security headers. It’s a port of the Node.js Helmet module. Add the shard and register the handlers early in your handler chain:

# shard.yml dependencies: helmet: github: EvanHahn/crystal-helmet 
require "kemal" require "helmet" # Add Helmet handlers (order matters – add early) add_handler Helmet::DNSPrefetchControllerHandler.new add_handler Helmet::FrameGuardHandler.new add_handler Helmet::InternetExplorerNoOpenHandler.new add_handler Helmet::NoSniffHandler.new add_handler Helmet::StrictTransportSecurityHandler.new(7.day) add_handler Helmet::XSSFilterHandler.new get "/" do "Hello World" end Kemal.run 

Each handler sets a specific header (e.g. X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security). See the Helmet documentation for options and customization.

Defense

Defense is a Crystal HTTP handler for throttling, blocking and tracking malicious requests (inspired by Rack::Attack). Add the shard and register the handler early in your handler chain:

# shard.yml dependencies: defense: github: defense-cr/defense 
require "kemal" require "defense" # Store: Redis (production) or MemoryStore (development/tests) Defense.store = Defense::RedisStore.new(url: ENV["REDIS_URL"]? || "redis://localhost:6379/0") # Defense.store = Defense::MemoryStore.new add_handler Defense::Handler.new # Throttle: 10 requests per minute per IP Defense.throttle("requests per minute", limit: 10, period: 60) do |request| request.remote_address.to_s end # Blocklist: block /admin for non-trusted IPs Defense.blocklist("block admin") do |request| request.path.starts_with?("/admin/") end # Safelist: never throttle/block localhost Defense.safelist("localhost") do |request| request.remote_address.to_s == "127.0.0.1" end get "/" do "Hello World" end Kemal.run 

Throttled and blocked responses are configurable via Defense.throttled_response= and Defense.blocked_response=. Defense also supports Fail2Ban and Allow2Ban for banning after repeated violations. See the Defense README for full options.

Deployment

Deploying a Kemal application to production requires careful consideration of build optimization, hosting platform, infrastructure setup, and operational best practices. This comprehensive guide covers everything you need to know to deploy your Kemal application successfully.

Production Build

Before deploying your Kemal application, you need to compile it for production with optimizations enabled.

Basic Release Build

Create an optimized production binary:

crystal build --release --no-debug src/your_app.cr 

Flags explained:

Static Linking

For maximum portability (especially for containers or cross-platform deployment), use static linking:

crystal build --release --static --no-debug src/your_app.cr 

The --static flag links all dependencies statically, producing a single binary with no external dependencies. This is ideal for Alpine Linux containers.

Build Optimization Tips

Reduce binary size:

# Strip additional symbols strip your_app # Enable link-time optimization crystal build --release --no-debug -Dpreview_mt src/your_app.cr 

Environment-specific builds:

# Set production environment during compilation KEMAL_ENV=production crystal build --release src/your_app.cr 

Docker Deployment

Docker provides consistent, reproducible deployments across different environments.

Multi-Stage Dockerfile

Create a Dockerfile in your project root:

# Build stage FROM crystallang/crystal:1.11.2-alpine AS builder WORKDIR /app # Copy shard files COPY shard.yml shard.lock ./ # Install dependencies RUN shards install --production # Copy source code COPY . . # Build the application RUN crystal build --release --static --no-debug src/your_app.cr -o bin/app # Runtime stage FROM alpine:latest WORKDIR /app # Install runtime dependencies (if needed) RUN apk add --no-cache libgcc # Copy compiled binary from builder COPY --from=builder /app/bin/app . # Copy public assets (if any) COPY --from=builder /app/public ./public # Expose port EXPOSE 3000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # Run the application CMD ["./app"] 

.dockerignore

Create a .dockerignore file to exclude unnecessary files:

.git .github *.md spec lib shard.lock tmp log *.log .env .env.* node_modules .DS_Store 

Docker Compose

For local development and testing with dependencies:

version: '3.8' services: app: build: . ports: - "3000:3000" environment: KEMAL_ENV: production DATABASE_URL: postgres://postgres:password@db:5432/myapp REDIS_URL: redis://redis:6379 depends_on: - db - redis restart: unless-stopped db: image: postgres:15-alpine environment: POSTGRES_DB: myapp POSTGRES_PASSWORD: password volumes: - postgres_data:/var/lib/postgresql/data redis: image: redis:7-alpine volumes: - redis_data:/data volumes: postgres_data: redis_data: 

Building and Running

# Build the image docker build -t my-kemal-app . # Run the container docker run -p 3000:3000 -e KEMAL_ENV=production my-kemal-app # Using Docker Compose docker-compose up -d 

Cloud Platforms

Deploy your Kemal application to popular cloud platforms with ease.

Heroku

Heroku provides a simple deployment experience with the official Crystal buildpack.

Setup:

  1. Create a Procfile in your project root:
web: ./your_app --port $PORT --bind 0.0.0.0 
  1. Add the Crystal buildpack:
heroku buildpacks:set https://github.com/crystal-lang/heroku-buildpack-crystal 
  1. Deploy:
git push heroku main 

Configuration:

# Set environment variables heroku config:set KEMAL_ENV=production # Add database heroku addons:create heroku-postgresql:mini # Scale dynos heroku ps:scale web=1 

Important: Configure your app to use the PORT environment variable:

Kemal.config.port = ENV["PORT"]?.try(&.to_i) || 3000 Kemal.config.host_binding = ENV["HOST"]? || "0.0.0.0" 

Fly.io

Fly.io offers excellent support for Crystal applications with their global deployment network.

Setup:

  1. Install the Fly CLI and authenticate:
curl -L https://fly.io/install.sh | sh fly auth login 
  1. Initialize your app:
fly launch 
  1. Create a fly.toml configuration (use dockerfile to build from your project’s Dockerfile):
app = "my-kemal-app" primary_region = "iad" [build] dockerfile = "Dockerfile" [env] KEMAL_ENV = "production" [http_service] internal_port = 3000 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 1 [[services]] protocol = "tcp" internal_port = 3000 [[services.ports]] port = 80 handlers = ["http"] [[services.ports]] port = 443 handlers = ["tls", "http"] [checks] [checks.health] grace_period = "5s" interval = "30s" method = "get" path = "/health" timeout = "2s" 
  1. Deploy:
fly deploy 

Add PostgreSQL:

fly postgres create fly postgres attach my-postgres-db 

Render

Render provides managed deployments with automatic SSL and CDN.

Setup:

Create a render.yaml file:

services: - type: web name: my-kemal-app env: docker plan: starter dockerfilePath: ./Dockerfile healthCheckPath: /health envVars: - key: KEMAL_ENV value: production - key: DATABASE_URL fromDatabase: name: myapp-db property: connectionString autoDeploy: true databases: - name: myapp-db plan: starter databaseName: myapp user: myapp 

Deploy:

  1. Connect your GitHub/GitLab repository
  2. Render will automatically detect render.yaml and deploy
  3. Every push to main branch triggers automatic deployment

Railway

Railway offers simple deployments with automatic configuration detection.

Setup:

  1. Install Railway CLI:
npm i -g @railway/cli railway login 
  1. Initialize and deploy:
railway init railway up 

Railway automatically detects Crystal applications and builds them appropriately.

Add services:

railway add postgres railway add redis 

Environment variables are automatically injected for added services.

DigitalOcean App Platform

DigitalOcean App Platform provides managed container deployments.

Setup:

Create an .do/app.yaml file:

name: my-kemal-app services: - name: web dockerfile_path: Dockerfile github: repo: username/repo branch: main deploy_on_push: true health_check: http_path: /health http_port: 3000 instance_count: 1 instance_size_slug: basic-xxs routes: - path: / envs: - key: KEMAL_ENV value: production - key: DATABASE_URL scope: RUN_TIME type: SECRET databases: - name: db engine: PG production: false 

Deploy via CLI:

doctl apps create --spec .do/app.yaml 

VPS and Bare Metal Deployment

For full control over your infrastructure, deploy to a VPS or bare metal server.

Server Setup

Prerequisites:

Systemd Service

Create a systemd service to manage your Kemal application.

1. Upload your compiled binary:

# On your development machine scp your_app user@server:/opt/myapp/ # On the server sudo mkdir -p /opt/myapp sudo chown -R www-data:www-data /opt/myapp 

2. Create a systemd service file:

Create /etc/systemd/system/kemal-app.service:

[Unit] Description=Kemal Application After=network.target postgresql.service [Service] Type=simple User=www-data Group=www-data WorkingDirectory=/opt/myapp ExecStart=/opt/myapp/your_app Restart=always RestartSec=10 # Environment variables Environment=KEMAL_ENV=production Environment=PORT=3000 Environment=HOST=127.0.0.1 # Environment file for secrets EnvironmentFile=/opt/myapp/.env # Security hardening NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/myapp/log /opt/myapp/tmp # Resource limits LimitNOFILE=65535 LimitNPROC=4096 [Install] WantedBy=multi-user.target 

3. Create environment file:

Create /opt/myapp/.env:

DATABASE_URL=postgres://user:password@localhost/myapp REDIS_URL=redis://localhost:6379 SECRET_KEY_BASE=your-secret-key-here 

4. Enable and start the service:

# Reload systemd sudo systemctl daemon-reload # Enable service to start on boot sudo systemctl enable kemal-app # Start the service sudo systemctl start kemal-app # Check status sudo systemctl status kemal-app # View logs sudo journalctl -u kemal-app -f 

Service management commands:

# Restart the service sudo systemctl restart kemal-app # Stop the service sudo systemctl stop kemal-app # Reload service configuration sudo systemctl daemon-reload sudo systemctl restart kemal-app 

Nginx Reverse Proxy

Use Nginx as a reverse proxy to handle SSL termination, static file serving, and load balancing.

1. Install Nginx:

sudo apt update sudo apt install nginx 

2. Create Nginx configuration:

Create /etc/nginx/sites-available/myapp:

upstream kemal { # Multiple instances for load balancing (optional) server 127.0.0.1:3000 max_fails=3 fail_timeout=30s; # server 127.0.0.1:3001 max_fails=3 fail_timeout=30s; # server 127.0.0.1:3002 max_fails=3 fail_timeout=30s; keepalive 32; } # HTTP server - redirect to HTTPS server { listen 80; listen [::]:80; server_name example.com www.example.com; # ACME challenge for Let's Encrypt location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; } # Redirect all HTTP to HTTPS location / { return 301 https://$server_name$request_uri; } } # HTTPS server server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name example.com www.example.com; # SSL configuration ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; # SSL settings ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # Logging access_log /var/log/nginx/myapp-access.log; error_log /var/log/nginx/myapp-error.log; # Max upload size client_max_body_size 50M; # Serve static files directly (if applicable) location /static/ { alias /opt/myapp/public/; expires 30d; add_header Cache-Control "public, immutable"; } # WebSocket support location /ws { proxy_pass http://kemal; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; } # Proxy to Kemal application location / { proxy_pass http://kemal; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; } # Health check endpoint location /health { proxy_pass http://kemal; access_log off; } } 

3. Enable the site:

# Create symbolic link sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ # Test configuration sudo nginx -t # Reload Nginx sudo systemctl reload nginx 

SSL with Let’s Encrypt

Secure your application with free SSL certificates from Let’s Encrypt.

1. Install Certbot:

sudo apt install certbot python3-certbot-nginx 

2. Obtain SSL certificate:

# Create webroot directory sudo mkdir -p /var/www/certbot # Obtain certificate sudo certbot --nginx -d example.com -d www.example.com 

3. Auto-renewal:

Certbot automatically creates a renewal timer. Verify it:

# Test renewal sudo certbot renew --dry-run # Check timer status sudo systemctl status certbot.timer 

Manual renewal hook (optional):

Create /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh:

#!/bin/bash systemctl reload nginx 
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 

Log Rotation

Configure log rotation to prevent disk space issues.

Create /etc/logrotate.d/kemal-app:

/opt/myapp/log/*.log { daily missingok rotate 14 compress delaycompress notifempty create 0640 www-data www-data sharedscripts postrotate # Optional: if your app reopens logs on SIGHUP, use: kill -HUP $(pgrep -f your_app) 2>/dev/null || true true endscript } 

Note: Kemal doesn’t have built-in log rotation support. Consider using Crystal’s Log with a backend that handles rotation, or copytruncate instead of create if your app must keep the file open.

Production Best Practices

Follow these best practices to ensure a robust production deployment.

Environment Configuration

Use environment variables for configuration:

require "kemal" # Configuration from environment Kemal.config.env = ENV["KEMAL_ENV"]? || "development" Kemal.config.port = ENV["PORT"]?.try(&.to_i) || 3000 Kemal.config.host_binding = ENV["HOST"]? || "0.0.0.0" # Database configuration DATABASE_URL = ENV["DATABASE_URL"]? || "postgres://localhost/myapp_dev" # Secret key for sessions, tokens, etc. SECRET_KEY = ENV["SECRET_KEY_BASE"]? || raise "SECRET_KEY_BASE is required" # Feature flags ENABLE_FEATURE_X = ENV["ENABLE_FEATURE_X"]? == "true" 

Never commit secrets to version control:

# .gitignore .env .env.* !.env.example config/secrets.yml 

Provide an example environment file:

Create .env.example:

KEMAL_ENV=production PORT=3000 HOST=0.0.0.0 DATABASE_URL=postgres://user:password@localhost/myapp REDIS_URL=redis://localhost:6379 SECRET_KEY_BASE=generate-a-secure-random-string-here 

Logging and Monitoring

Configure production logging:

require "kemal" # Set environment Kemal.config.env = ENV["KEMAL_ENV"]? || "production" # Enable logging Kemal.config.logging = true # Use structured logging Log.setup do |c| backend = Log::IOBackend.new if Kemal.config.env == "production" # JSON logging for production backend.formatter = Log::Formatter.new do |entry, io| { timestamp: Time.utc, level: entry.severity.to_s, message: entry.message, source: entry.source }.to_json(io) end c.bind "*", :info, backend else # Human-readable for development c.bind "*", :debug, backend end end 

Application logging:

# Use Crystal's Log Log.info { "User #{user_id} logged in" } Log.warn { "Rate limit exceeded for IP #{ip}" } Log.error { "Database connection failed: #{error}" } 

Monitor application health:

# Add health check endpoint get "/health" do |env| env.response.content_type = "application/json" # Check database connectivity db_healthy = begin DB.open(DATABASE_URL) { |db| db.query_one("SELECT 1", as: Int32) } true rescue false end # Check Redis connectivity redis_healthy = begin Redis.new(url: REDIS_URL).ping true rescue false end status = db_healthy && redis_healthy ? "healthy" : "unhealthy" env.response.status_code = status == "healthy" ? 200 : 503 { status: status, timestamp: Time.utc.to_rfc3339, checks: { database: db_healthy ? "up" : "down", redis: redis_healthy ? "up" : "down" } }.to_json end # Readiness check (for Kubernetes) get "/ready" do |env| env.response.content_type = "application/json" {"status" => "ready"}.to_json end # Liveness check (for Kubernetes) get "/live" do |env| env.response.content_type = "application/json" {"status" => "alive"}.to_json end 

Graceful Shutdown

Ensure your application shuts down gracefully, completing in-flight requests.

require "kemal" # Configure graceful shutdown Kemal.config.shutdown_timeout = 10.seconds # Your routes... get "/" do "Hello World" end Kemal.run 

Deployment Strategies

Zero-Downtime Deployment

Deploy new versions without service interruption.

Using Nginx upstream:

Update your Nginx configuration to use multiple upstream servers:

upstream kemal { server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; } 

Create per-port systemd services (e.g. /etc/systemd/system/[email protected]):

[Unit] Description=Kemal Application (port %i) After=network.target [Service] Type=simple User=www-data WorkingDirectory=/opt/myapp ExecStart=/opt/myapp/your_app Restart=always Environment=PORT=%i EnvironmentFile=/opt/myapp/.env [Install] WantedBy=multi-user.target 

Enable with systemctl enable kemal-app@{3000,3001,3002} so each instance runs on a different port.

Deployment script:

Create scripts/deploy.sh:

#!/bin/bash set -e APP_DIR="/opt/myapp" PORTS=(3000 3001 3002) echo "Building new version..." crystal build --release --no-debug src/your_app.cr -o your_app.new echo "Deploying with zero downtime..." # Replace binary once (all instances share the same binary) cp your_app.new $APP_DIR/your_app for PORT in "${PORTS[@]}"; do echo "Deploying to instance on port $PORT..." # Restart instance (uses [email protected] template) sudo systemctl restart kemal-app@$PORT # Wait for health check sleep 5 # Check if healthy if curl -f http://localhost:$PORT/health > /dev/null 2>&1; then echo "Instance on port $PORT is healthy" else echo "Instance on port $PORT failed health check!" exit 1 fi # Wait before next instance sleep 2 done echo "Deployment complete!" 

Running Multiple Instances

Use reuse_port to run multiple instances on the same port.

Enable SO_REUSEPORT:

require "kemal" # Configure the server to reuse the port Kemal.config.server.not_nil!.bind_tcp( Kemal.config.host_binding, Kemal.config.port, reuse_port: true ) # Your routes... get "/" do "Hello from process #{Process.pid}" end Kemal.run 

Create multiple systemd services:

# Create service instances sudo systemctl enable kemal-app@{1,2,3,4} sudo systemctl start kemal-app@{1,2,3,4} 

Or use a single service (one instance per service file):

[Unit] Description=Kemal Application After=network.target [Service] Type=simple User=www-data WorkingDirectory=/opt/myapp ExecStart=/opt/myapp/your_app Restart=always EnvironmentFile=/opt/myapp/.env [Install] WantedBy=multi-user.target 

Database Migrations

Integrate database migrations into your deployment workflow.

Pre-deployment migration script:

#!/bin/bash set -e echo "Running database migrations..." # Using Micrate (Crystal migration tool) DATABASE_URL=$DATABASE_URL ./bin/micrate up if [ $? -eq 0 ]; then echo "Migrations completed successfully" else echo "Migration failed!" exit 1 fi 

Safe migration practices:

-- migrations/001_add_users_table.sql -- Always use IF NOT EXISTS for safety CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); -- migrations/002_add_index.sql -- Create indexes concurrently (PostgreSQL) CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email); 

Include in deployment:

# Deploy script with migrations ./scripts/migrate.sh && \ ./scripts/deploy.sh 

Continuous Deployment

Automate your deployment process with CI/CD pipelines.

GitHub Actions

Create .github/workflows/deploy.yml:

name: Deploy to Production on: push: branches: [main] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Install Crystal uses: crystal-lang/install-crystal@v1 with: crystal: latest - name: Install dependencies run: shards install - name: Run tests run: crystal spec env: DATABASE_URL: postgres://postgres:postgres@localhost/test build: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: $ password: $ - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: ghcr.io/$:latest cache-from: type=gha cache-to: type=gha,mode=max deploy: needs: build runs-on: ubuntu-latest steps: - name: Deploy to production uses: appleboy/ssh-action@master with: host: $ username: $ key: $ script: | cd /opt/myapp docker pull ghcr.io/$:latest docker-compose up -d docker system prune -f 

GitLab CI/CD

Create .gitlab-ci.yml:

stages: - test - build - deploy variables: DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA test: stage: test image: crystallang/crystal:latest services: - postgres:15 variables: POSTGRES_DB: test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres DATABASE_URL: postgres://postgres:postgres@postgres/test script: - shards install - crystal spec only: - main - merge_requests build: stage: build image: docker:latest services: - docker:dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $DOCKER_IMAGE . - docker push $DOCKER_IMAGE - docker tag $DOCKER_IMAGE $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest only: - main deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - mkdir -p ~/.ssh - chmod 700 ~/.ssh - ssh-keyscan $SERVER_HOST >> ~/.ssh/known_hosts script: - | ssh $SERVER_USER@$SERVER_HOST << EOF cd /opt/myapp docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY docker pull $DOCKER_IMAGE docker-compose up -d docker system prune -f EOF only: - main environment: name: production url: https://example.com 

Performance Tuning

Optimize your Kemal application for maximum performance.

Static File Serving

For better performance, serve static files with Nginx instead of Kemal:

# Disable Kemal's static file handler in production if Kemal.config.env == "production" Kemal.config.serve_static = false end 

Then configure Nginx to serve static files directly:

location /assets/ { alias /opt/myapp/public/assets/; expires 1y; add_header Cache-Control "public, immutable"; access_log off; } 

Gzip Compression

Enable gzip compression in Nginx for text-based responses:

# Enable gzip gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml; gzip_disable "msie6"; 

Connection Pooling

crystal-db provides a built-in connection pool. DB.open returns a DB::Database object that manages the pool—you don’t need to configure it manually. Configure the pool via query string parameters in your connection URI.

Pool parameters (see Crystal Connection Pool docs):

Parameter Default Description
initial_pool_size 1 Connections created when opening the database
max_pool_size 0 Maximum connections (0 = unlimited)
max_idle_pool_size 1 Max idle connections before closing excess
checkout_timeout 5.0 Seconds to wait for an available connection
retry_attempts 1 Retries on connection loss
retry_delay 1.0 Seconds between retries
require "db" require "pg" # Configure pool via URI query parameters # postgres://user:pass@host/db?initial_pool_size=5&max_pool_size=25&checkout_timeout=5&retry_attempts=3 DB = DB.open(ENV["DATABASE_URL"]? || "postgres://localhost/myapp?initial_pool_size=5&max_pool_size=25&max_idle_pool_size=10&checkout_timeout=5&retry_attempts=3&retry_delay=1") # Use in routes - db.query, db.exec, etc. automatically use the pool get "/users" do |env| users = DB.query_all("SELECT * FROM users", as: User) users.to_json end 

When using db.query, db.exec, db.scalar, etc., the pool automatically checks out a connection, runs the statement, and returns it to the pool. If the connection is lost, it retries according to retry_attempts and retry_delay.

Caching Strategies

Implement caching for expensive operations:

require "redis" # Initialize Redis REDIS = Redis.new(url: ENV["REDIS_URL"]) # Cache expensive queries (Post is your model/struct type) get "/popular-posts" do |env| cache_key = "popular_posts" # Try cache first cached = REDIS.get(cache_key) if cached env.response.content_type = "application/json" next cached end # Compute if not cached posts = DB.query_all("SELECT * FROM posts ORDER BY views DESC LIMIT 10", as: Post) result = posts.to_json # Cache for 5 minutes REDIS.setex(cache_key, 300, result) env.response.content_type = "application/json" result end 

HTTP/2 and Keep-Alive

Enable HTTP/2 in Nginx for better performance:

listen 443 ssl http2; listen [::]:443 ssl http2; # Keep-alive settings keepalive_timeout 65; keepalive_requests 100; 

Capistrano

For traditional deployment workflows, you can use capistrano-kemal to deploy your Kemal application to any server with automated deployment scripts.

Cross-compilation

Cross-compile your Kemal application for different platforms.

Basic cross-compilation:

# Compile for Linux (from macOS) crystal build --cross-compile --target x86_64-unknown-linux-gnu src/your_app.cr 

This generates a .o file and a linker command. You’ll need to run the linker command on the target platform.

Docker-based cross-compilation:

A more practical approach is using Docker:

# Create a builder container docker run --rm -v $(pwd):/app -w /app crystallang/crystal:latest \ crystal build --release --static --no-debug src/your_app.cr -o bin/app-linux 

Multi-platform Docker builds:

# Build for multiple architectures docker buildx create --use docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest . 

For more details, see the official Crystal cross-compilation guide.

Improve this guide

Please help us improve this guide with pull requests to this website repository.