A self-hosted CardDAV server as a Rails Engine. Mount it in any Rails 8+ app for contact sync with DAVx5, Apple Contacts, Thunderbird, and other CardDAV clients.
Implements RFC 6352 (CardDAV), RFC 4918 (WebDAV), and RFC 6578 (WebDAV Sync).
- Full CardDAV protocol support (sync, multiget, addressbook-query)
- Web UI for managing contacts and addressbooks
- Import/export in vCard (.vcf), CSV, and JSON formats
- Addressbook sharing between users (read/write permissions)
- Public share links
- Session-based auth for web UI, HTTP Basic Auth for CardDAV clients
- vCard data stored as-is — never parsed into columns
rails new my-contacts --skip-active-storage
cd my-contactsAdd to your Gemfile:
gem "railsdav"bundle install
bin/rails db:migrateAdd a root route in config/routes.rb:
Rails.application.routes.draw do
root to: redirect("/login")
endEnable registration in config/initializers/railsdav.rb:
Railsdav.configure do |config|
config.allow_registration = true
endbin/rails serverOpen http://localhost:3000, register an account, and point your CardDAV client at:
http://localhost:3000/.well-known/carddav
Create config/initializers/railsdav.rb:
Railsdav.configure do |config|
# Display name shown in the web UI header
config.site_name = "Contacts"
# Base URL used in DAV sync-token URLs (required for CardDAV sync)
config.site_url = "https://contacts.example.com"
# Layout template to use (default: "railsdav/application")
config.layout = "railsdav/application"
# Allow new user registration (default: false)
config.allow_registration = true
# Optional callback to gate DAV access (e.g. require a subscription)
# Return true to allow, or a Rack response array [status, headers, body] to deny.
config.dav_auth_callback = ->(user, env) {
if user.paid?
true
else
[402, { "Content-Type" => "text/plain" }, ["Payment Required"]]
end
}
endRailsdav is a full (non-isolated) Rails engine. It owns models, migrations, controllers, views, and middleware. Your host app must satisfy these contracts.
Your config/application.rb must load:
require "active_record/railtie"
require "active_job/railtie" # for deliver_later (even :async adapter works)
require "action_mailer/railtie" # engine sends invitation emailsSession middleware must be enabled (Rails default cookie sessions are fine).
The engine creates 5 tables via migrations 001–008: users, addressbooks, contacts, sync_changes, addressbook_shares. Running bin/rails db:migrate picks them up automatically — the engine appends its migration path to the host's.
You can add columns to users (e.g. admin), but must not rename or remove columns the engine created. SQLite, PostgreSQL, and MySQL all work.
The engine sends invitation emails when users share addressbooks. Your host must configure a delivery method in production:
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { address: "smtp.example.com", ... }
config.action_mailer.default_url_options = { host: "contacts.example.com", protocol: "https" }Set the sender address via config.mailer_from (see Configuration above). The engine uses deliver_later, so Active Job must be available — even the default :async adapter is sufficient.
The engine injects routes for: /login, /logout, /register, /addressbooks, /invitations/:token/accept, /profile, /s/:token (public shares), and /up (health check). The CardDAV middleware handles /.well-known/carddav and /dav/ at the Rack level.
Your host must:
- Define a
rootroute (the engine redirects toaddressbooks_pathafter login but does not define root) - Avoid conflicting routes at the paths listed above
The engine owns User with username, email, password_digest, has_secure_password, and several callbacks. Extend it from an initializer:
# config/initializers/user_extensions.rb
Rails.application.config.to_prepare do
User.class_eval do
has_one :subscription, dependent: :destroy
def paid?
subscription&.active?
end
end
endDo not remove has_secure_password, override create_default_addressbook or claim_pending_invitations callbacks, or change how email is normalized.
The engine provides a default layout at layouts/railsdav/application. To use your own:
Railsdav.configure do |config|
config.layout = "application" # your app/views/layouts/application.html.erb
endIf you override the layout, it must include: CSRF meta tags (<%= csrf_meta_tags %>), <%= yield %>, and flash message display. Link the engine stylesheet for correct icon/component sizing:
<%= stylesheet_link_tag "railsdav/application", "data-turbo-track": "reload" %>The engine inserts CardDav::Middleware before Rails::Rack::Logger. It intercepts all requests to /dav/ and /.well-known/carddav. Do not remove, reorder, or add middleware that rewrites these paths.
If your test suite uses FactoryBot, the engine auto-prepends its factory paths so engine factories load first. Extend them with FactoryBot.modify:
# spec/factories/users.rb (host app)
FactoryBot.modify do
factory :user do
trait :admin do
admin { true }
end
end
end| Path | Description |
|---|---|
/.well-known/carddav |
Discovery endpoint (redirects to /dav/) |
/dav/ |
DAV context root |
/dav/{username}/ |
User principal |
/dav/{username}/contacts/ |
Addressbook home set |
/dav/{username}/contacts/{uri}/ |
Addressbook collection |
/dav/{username}/contacts/{uri}/{card}.vcf |
Individual vCard |
/login |
Web UI login |
/register |
Web UI registration |
/addressbooks |
Web UI dashboard |
bundle exec rspec- RFC 6352 — CardDAV
- RFC 4918 — WebDAV
- RFC 6578 — WebDAV Sync
- RFC 3744 — WebDAV ACL (partial, for current-user-principal)
- RFC 5397 — current-user-principal
- RFC 6764 — SRV/well-known discovery
- RFC 6350 — vCard 4.0
MIT — see LICENSE.