Skip to content

Standalone binary

Run the Kalendee fat jar or distZip release directly under Java 21, with a service user and a systemd unit.

Artifacts

There are two equivalent ways to run the server without Docker. Both need Java 21 and a PostgreSQL 17 database you manage yourself.

ArtifactBuilt byPathRun
Fat jar./gradlew :server:buildFatJarserver/build/libs/server-all.jarjava -jar server-all.jar
Distribution zip./gradlew :server:distZipserver/build/distributions/server-<version>.zipbin/server inside the unzipped directory
Release jarGitHub Releasekalendee-server-<version>.jarSame as the fat jar.
Release zipGitHub Releasekalendee-server-<version>.zipSame as the distribution.
Web packGitHub Releasekalendee-<version>.febOnly for standalone pack deployments; the jar already bundles the pack.

The fat jar (server-all.jar) is the recommended artifact: it contains the server, all dependencies, and the built Keel web pack. The distZip is the Gradle application plugin’s layout (a bin/ launcher plus lib/ jars) if you prefer a directory-style install.

Build from source

Java 21 is required; the Gradle wrapper fetches the toolchain if needed. The build also builds the Keel pack, which requires Node 22+ and pnpm 11.25.0.

./gradlew :server:buildFatJar
ls -lh server/build/libs/server-all.jar

# or the distribution layout:
./gradlew :server:distZip
ls -lh server/build/distributions/server-*.zip

To build a release artifact with an explicit name, the release workflow uses ./gradlew :server:buildFatJar :server:distZip -Pversion=<version>, which is what produces server-<version>.zip.

Run the fat jar

java -jar /opt/kalendee/server-all.jar \
    -config=/etc/kalendee/application.conf

-config=<path> is how the jar is told which HOCON file to load. This is the same precedence the container entrypoint implements, but the jar itself only understands -config; it does not read KALENDEE_CONFIG. If you want environment-driven selection, wrap the command:

exec java -jar /opt/kalendee/server-all.jar -config="${KALENDEE_CONFIG:-/etc/kalendee/application.conf}"

The full resolution order for a bare jar is:

PrioritySource
1-config=<path> (or -config <path>) on the command line.
2application.conf packaged in the jar inside the classpath. There is no mounted or baked step here.
3Values embedded in that same jar config. Environment variables are still substituted into it via ${?VAR}.

Environment variables (the ones used by ${?VAR} fallbacks such as KALENDEE_DATABASE_PASSWORD) must be present in the process environment. The .env.local file is loaded by the Gradle :server:run task only; the running jar does not read it.

If no config is passed, the jar starts on its baked defaults (database.url = jdbc:postgresql://127.0.0.1:5432/kalendee) but still fails fast if KALENDEE_DATABASE_PASSWORD is unset.

Run the distribution

unzip server-0.1.0.zip -d /opt/kalendee
/opt/kalendee/server-0.1.0/bin/server -config=/etc/kalendee/application.conf

The launcher respects the usual JAVA_HOME and JAVA_OPTS environment variables and forwards trailing arguments to the application.

Configuration and secrets

Start from the committed example and keep secrets out of the file:

sudo install -d -m 0750 -o root -g kalendee /etc/kalendee
sudo cp application.conf.example /etc/kalendee/application.conf
sudo chown root:kalendee /etc/kalendee/application.conf
sudo chmod 0640 /etc/kalendee/application.conf

The ${?VAR} lines in the file are filled from the process environment, so put secrets in a root-owned environment file (mode 0600) and reference it from systemd. Never put the database password, KALENDEE_SECRET_KEY, SMTP password, or OAuth client secrets directly in application.conf. See Configuration and Security.

Create the service user

The server writes only to the avatar directory (unless you use S3) and needs read access to its jar and config. Run it as a dedicated, non-login user:

sudo useradd --system --home /opt/kalendee --shell /usr/sbin/nologin kalendee
sudo install -d -o kalendee -g kalendee /var/lib/kalendee/avatars
sudo install -m 0755 server-all.jar /opt/kalendee/server-all.jar

systemd unit

/etc/systemd/system/kalendee.service:

[Unit]
Description=Kalendee CalDAV server
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=kalendee
Group=kalendee
WorkingDirectory=/opt/kalendee
EnvironmentFile=/etc/kalendee/kalendee.env
ExecStart=/usr/bin/java \
    -Xms128m -Xmx512m \
    -jar /opt/kalendee/server-all.jar \
    -config=/etc/kalendee/application.conf
Restart=on-failure
RestartSec=5

# Hardening (the server needs no privileges).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/kalendee
CapabilityBoundingSet=
AmbientCapabilities=

[Install]
WantedBy=multi-user.target

/etc/kalendee/kalendee.env (root-owned, mode 0600) carries secrets and the avatar location the HOCON expects:

KALENDEE_DATABASE_URL=jdbc:postgresql://127.0.0.1:5432/kalendee
KALENDEE_DATABASE_USER=kalendee
KALENDEE_DATABASE_PASSWORD=change-me
KALENDEE_ADMIN_PASSWORD=change-me
KALENDEE_PUBLIC_URL=https://calendar.example.com
# KALENDEE_AVATAR_DIR must match a path inside ReadWritePaths.
KALENDEE_AVATAR_DIR=/var/lib/kalendee/avatars

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now kalendee
systemctl status kalendee

Logs

Logback writes to stdout by default, so systemd captures it in the journal:

journalctl -u kalendee -f
journalctl -u kalendee --since "10 min ago"

The baked logback.xml sets the root logger to trace, which is noisy in production. Override it with a config you control:

java -Dlogback.configurationFile=/etc/kalendee/logback.xml \
    -jar /opt/kalendee/server-all.jar -config=/etc/kalendee/application.conf

Add the same -D to ExecStart in the unit. A minimal override sets the root logger to INFO.

Health check and operations

curl -fsS http://localhost:8080/api/v1/health   # {"status":"ok"}

The endpoint verifies the database connection, so it fails until the database is reachable and migrations have run. On upgrade, apply the same order as the container path: back up, replace the jar, restart, and let Flyway migrate. See Backups and upgrades.

Edit this page on GitHub