#NotusShip on your own server

Target: running in 15 minutes. All you need is a server with Docker installed.

The image lives in a private registry; Notussoft grants access and usually performs the installation. If you are going to use a managed PostgreSQL, see section 1b — handing the application your own database server changes a few settings.

#1. Installation

#Requirements

  • Docker Engine 24+ and Docker Compose v2
  • 2 GB RAM, 10 GB disk (ample for a small team)
  • A domain name and a reverse proxy terminating TLS in front of it (Caddy, nginx, Traefik, Coolify…)

#Steps

Today Notussoft performs these steps. The product ships from a private registry and Notussoft installs it on your server (docs/dagitim.md, stage 1). The document is written out in full regardless: you may want to see what happens, audit the installation, or take it over yourself one day.

Three things are needed for the installation:

WhatWhyWho holds it
docker-compose.yml and .env.exampleThe package. The source repository is private, so you cannot download them yourselfNotussoft sends them
A registry username and tokenTo pull the image from ghcr.ioUsed by Notussoft during installation; not handed over by default
A licence key (optional)Without one the installation runs on the free tier of 3 users. You can ask for a free 30-day, 25-seat trial keyNotussoft issues it, within 1 business day of the request
If you want the credential yourself — to run your own upgrades — that is agreed separately and you are given a read-only credential of your own (docs/dagitim.md, stage 2). It is not the default path; upgrades are also Notussoft's job today (§3).
# 1) Put the two files Notussoft sent you in an empty directory, then:
cp .env.example .env

# 2) Sign in to the registry with the credentials you were given
#    Without this step, step 4 fails with "unauthorized".
echo "<token>" | docker login ghcr.io -u "<username>" --password-stdin

# 3) Fill in the three required values in .env
openssl rand -base64 24   # → POSTGRES_PASSWORD
openssl rand -base64 48   # → NotusShip__Jwt__SigningKey
#                           → NotusShip__BaseUrl=https://notusship.yourcompany.com

# 4) Start it
docker compose up -d

# 5) Wait for it to come up (about 15 seconds)
docker compose ps
Fill in .env before the first start. PostgreSQL writes the password only when it creates its data directory; if you start with a wrong value and correct it afterwards, the old password stays and the application cannot connect. See section 6 for the way out.

When the STATUS column reads healthy, open your address in a browser.

#The first user

The setup screen has three steps: account → organization → first project. Your board opens with three example issues.

Registration closes after the first user. On an installation exposed to the internet we do not want anyone to be able to sign up and see your data. You add your team with the command in section 2.

#Reverse proxy

The application speaks plain HTTP on port 8080; the proxy terminates TLS.

The port is bound to 127.0.0.1 only, and that is a security condition, not a preference. Two reasons: the application does not speak TLS, and rate limiting trusts the X-Forwarded-For header written by the proxy — anyone able to reach the application directly could forge that header and slip past the limit. If your proxy runs on another machine you have to change the binding in docker-compose.yml; in that case restrict access with a firewall so that only the proxy's address can reach it.

Caddy example
notusship.yourcompany.com {
    reverse_proxy localhost:8080
}

NotusShip__BaseUrl must be the address your users see, not localhost:8080.


#1b. Managed (external) PostgreSQL

Instead of the db service that ships with the package you can use a managed database — RDS, Supabase, Hetzner, Coolify's own Postgres resource, any of them.

A different compose file:

curl -O https://raw.githubusercontent.com/notussoft/notusship/main/deploy/docker-compose.external-db.yml

There is no db service and no notusship-db volume; everything else is the same.

⚠ You create the empty database yourself. The application creates the schema (migrations run at start-up) but not the database itself. You do not see this in the packaged installation because the Postgres image creates the database on first start. A managed service does not do that: the provider hands you a database under its own name, and the name in your connection string is not there. Create it before you connect.

A different variable. Instead of POSTGRES_PASSWORD you give a full connection string:

VariableValue
ConnectionStrings__DefaultHost=db.example.com;Port=5432;Database=notusship;Username=notusship;Password=…;SSL Mode=Require;Trust Server Certificate=true

We do not assemble it from parts because managed services usually want SSL and pooling settings too; a single string is pasted in exactly as the provider gives it. The remaining variables (section 5) are the same.

⚠ The server version must be 17 or lower. The pg_dump client in the image is 17; against a server on 18+ backups cannot run. The application writes this to the log at start-up (Backups will not run: …), but noticing it after the installation means days without a backup. If you choose the version when creating the database, stay on 17.

The application has to be able to reach the database. If the managed database sits on another network, open access to it; use the internal address your provider gives you — a public address sends the traffic on a needless trip through the internet.

Prove it before installing:

docker compose run --rm app sh -c 'apk add --no-cache postgresql-client >/dev/null 2>&1; \
  PGPASSWORD=<password> psql -h <host> -U <user> -d <database> -c "select 1"'

The restore procedure is different too — with no db container, the application container runs the command: runbook.md §2b.


#2. Adding your team

docker compose exec app dotnet NotusShip.Api.dll add-user \
  merve@yourcompany.com "Merve Y." "a-strong-password" Member

Roles: Owner (everything), Member (the default — reads and writes), Viewer (reads only).

The user signs in with that password.

You can also invite people from inside the application (Settings → Members → Invite); if SMTP is configured the invitation is emailed, and either way the link is shown on screen so you can pass it on yourself.

#Interface language

NotusShip speaks Turkish and English. It opens in your browser's language; the Türkçe / English control at the top right of the sign-in screen and the setup wizard changes it.

The language chosen by whoever installs it becomes the starting language for new members — so you do not have to set it person by person after adding your team. To change it later: organization-wide in Settings → Organization, personally in Settings → Preferences.


#2b. Signing in with a company account (SSO / OIDC)

If your team uses Entra ID, Google Workspace, or any other provider that speaks OIDC, sign-in can be handed over to it. There is no service in between — NotusShip talks to your provider directly; you do not need an Auth0 account.

Configure it as an Owner: side menu → Single sign-on.

Order matters. Register the redirect URI shown on that screen with your provider first; you can only get a client ID afterwards:

https://notusship.yourcompany.com/api/auth/sso/callback

Then fill in three fields:

FieldWhere it comes from
Issuer addressYour provider's OIDC discovery address. Entra ID: https://login.microsoftonline.com/<tenant-id>/v2.0; Google: https://accounts.google.com
Client IDFrom the app registration at your provider
Client secretOnly if your provider requires one. Not needed for a registration that works with PKCE

Allowed email domains are required and cannot be empty. Any Google account can authenticate against a Google Workspace client; without the list, an outsider could sign in and land in your team. Subdomains do not count: with yourcompany.com allowed, x.yourcompany.com cannot sign in.

Roles stay in NotusShip. Provider groups are not mapped to roles; the first person to sign in becomes a Member and you change the role here.

The owner can always sign in with a password. The "members cannot sign in with a password" setting does not cover the owner, so a misconfigured provider cannot lock you out of your own installation.

⚠ If you change NotusShip__Jwt__SigningKey, re-enter the client secret. The secret is encrypted with a key derived from that signing key. The product does not fail silently: the settings screen shows a "the stored client secret cannot be decrypted" warning.

#2c. Webhooks and the public API

Public API. Create a permanent key (side menu → API keys) and call the API with an Authorization: Bearer nsp_… header. The full endpoint list lives at https://notusship.yourcompany.com/openapi/v1.json; the document requires authentication, so you can download it with your key.

There is no version prefix (/api/v1) in the paths. The rule is: fields are added, never removed — ignore fields you do not recognize.

Webhooks. Side menu → Webhooks (owner only). One address, a list of events, and a signing secret the product generates. The secret is shown once.

The request looks like this:

POST /your/address
Content-Type: application/json
X-NotusShip-Event: IssueCreated
X-NotusShip-Delivery: 0199...        # stays the same if the event is retried
X-NotusShip-Timestamp: 1756645200
X-NotusShip-Signature: sha256=...

Verify the signature like this (Python):

import hashlib, hmac, time

def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:   # reject anything older than 5 minutes
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

What you need to know:

  • A webhook is organization-wide. Events from private projects are sent too.
  • Order is not guaranteed. Deliveries run in parallel; do not rely on order.
  • The body is a pointer. Read details from the API; the field list will grow.
  • After 15 consecutive failures the webhook turns itself off, and the reason is shown on screen.
  • If backups are off and no SMTP is configured, the background queue is never set up; delivery then happens inline and is not retried. Keep the daily backup on if you want durable delivery.
⚠ If you change NotusShip__Jwt__SigningKey, recreate your webhooks. The signing secret is encrypted with a key derived from it; after a change it cannot be decrypted and the screen warns you.

#2d. GitHub connection

Commits and pull requests get linked to cards: an NSHIP-42 in the commit message, the PR title, or the branch name is enough.

No GitHub token is stored. The connection is one-way — GitHub sends us events, we never call GitHub. The trade-off: your installation must be reachable from GitHub. On a closed network this feature will not work.

Setup (side menu → Git connection, owner only):

  1. Generate a secret and copy the value shown — it is not shown again.
  2. In GitHub: repository → Settings → Webhooks → Add webhook.
  3. Payload URL: the address on the screen. Content type: application/json. Secret: the value you just copied.
  4. Which events? → Let me select individual events → Pushes and Pull requests.
  5. Save. GitHub sends a ping; if "last event" fills in on the settings screen, the connection works.

You can paste the same secret into several repositories — the secret is per-organization.

⚠ Regenerating the secret invalidates the old one immediately. You must update the webhook in every repository. The same applies when NotusShip__Jwt__SigningKey changes: the settings screen warns you, and you regenerate the secret.

#3. Upgrading

Read CHANGELOG.md first. If there is no "⚠ Upgrade note", the three commands below are enough; if there is one, it tells you about a manual step.

docker compose exec app dotnet NotusShip.Api.dll backup-now   # back up first
docker compose pull
docker compose up -d

Migrations are applied automatically at start-up and written to the log.

#Confirming the upgrade actually happened

docker compose exec app dotnet NotusShip.Api.dll version

The output looks like 0.2.0 or 0.2.0+abc1234. The version is embedded at build time; looking at the image tag is not enough, because latest looks identical on every release. The same value is shown in the interface: at the bottom of the side panel, under your name.

Include this value when you report a fault — it is the first thing you will be asked.

#The backward-compatibility promise

An upgrade never loses data. Dropping and renaming a column happens in two stages: one release adds the new column and writes to both, and only a later release removes the old one.

Going back one version is supported, going back two is not — the migrations in between are not reversed.

#If something went wrong after an upgrade

# 1) Go back to the previous version (point NOTUSSHIP_IMAGE in .env at the old tag)
docker compose up -d

# 2) If the database has to come back too: the restore procedure is in the runbook

You do not need to touch the database to go back one version; the columns the new version added are ignored by the old one. A restore is only needed if the migration itself broke — procedure and drill results in runbook.md.

On the first upgrade to this release, users must hard-refresh once (Cmd/Ctrl + Shift + R). Earlier versions served index.html as cacheable; the copy left in the browser asks for file names that no longer exist in the new version, and the application does not open. The server cannot recall a stale copy from someone's cache. This will not recur after this release: index.html is now no-cache, and assets are fingerprinted and long-lived.

To pin a specific version, in .env:

NOTUSSHIP_IMAGE=ghcr.io/notussoft/notusship:v1.0.0

We recommend this in production: running on latest means not knowing when you were upgraded.


#3b. Monitoring

The application exposes three probe endpoints, and they do different jobs:

EndpointWhat it says
/healthIs the process up. Does not look at the database — a container must not be restarted because the database blipped
/health/readyIs the database reachable and the schema in place. Your reverse proxy's traffic decision should depend on this
/health/operationsAre backups actually being taken, is the license about to stop writes

Wire the third one to an uptime monitor (Uptime Kuma, Better Stack, Cloudflare health checks, Coolify's notifications — whichever you use). When unhealthy it returns 503; the body names the failing probe, the detail is in the log.

Why this is a separate endpoint: the other way to learn that backups are not working is the moment you need a restore. And a stale backup must not influence a traffic decision — which is why it was not folded into ready.

Expect a 503 on the first day after installation: the first backup is taken at 02:00 UTC.


#3c. GDPR / KVKK requests

Access (GDPR art. 15 / KVKK art. 11). Users can download their own data: Settings → Preferences → My data. A single JSON file, scoped to that organization. Nothing for the operator to do.

Erasure (GDPR art. 17 / KVKK art. 7). Two steps, and the order matters:

# 1) Remove the person from the team: Settings → Members → Remove
# 2) Erase their identity
docker compose exec app dotnet NotusShip.Api.dll anonymize-user leaver@yourcompany.com

The command asks for confirmation (you have to type the email address again) and is irreversible — the only way back is a backup.

What it does: the person's name becomes Silinmiş kullanıcı — that value is stored, so it reads the same in every language — their email is changed to an unreachable address, their password is removed, and their notifications and saved filters are deleted. The content they produced and the issue history remain.

That is deliberate: deleting their comments and time entries would make it impossible for the team to read what happened six months ago. The purpose of the right to erasure is that the person's identity disappears; anonymization does that. You may need to explain this distinction to them.

If the user is still a member of an organization the command refuses and tells you the order.


#4. Backups

An automatic backup is taken every night at 02:00 UTC and kept for 14 days.

docker compose exec db ls -lh /backups                        # list backups
docker compose exec app dotnet NotusShip.Api.dll backup-now   # take one now

Restore procedure and drill results: runbook.md.

If your platform has backups of its own (Coolify, your managed database provider…) you can turn ours off with NotusShip__Backup__Enabled=false. We recommend keeping both on for the first few months: our backup's restore path has been rehearsed and the result is in the runbook, yours has not been yet.

Copy the notusship-backups and notusship-files volumes off the server — if the server disappears entirely, a backup that lives on it is no help.


#5. Configuration

Every setting is an environment variable. If a required one is missing the application refuses to start with an explicit message and the container exits with code 1; it does not run quietly in a wrong state.

VariableRequiredDefaultDescription
POSTGRES_PASSWORD✔—Database password
NotusShip__Jwt__SigningKey✔—At least 32 characters. Changing it ends every session
NotusShip__BaseUrl✔—The externally visible address
NOTUSSHIP_PORT8080Port on the host
NOTUSSHIP_IMAGE…:latestImage tag
NotusShip__Backup__EnabledtrueNightly backup
NotusShip__Backup__HourUtc2Backup hour (UTC)
NotusShip__Backup__RetentionDays14Retention period
NOTUSSHIP_LICENSE—License key. Without one, the free tier of 3 users. If it expires the installation falls back to the free tier for up to 3 users
ConnectionStrings__Default✔¹—Only in the external-database package; it replaces POSTGRES_PASSWORD (section 1b)
NotusShip__Email__Host—Set it and email turns on. Leave it empty and password recovery does not work — see below
NotusShip__Email__Port587
NotusShip__Email__UseStartTlstrue
NotusShip__Email__User—
NotusShip__Email__Password—Stays in .env; the application never writes it to the log
NotusShip__Email__FromAddress—Sender address. Falls back to USER when empty
NotusShip__Email__FromNameNotusShip

¹ Required if you use docker-compose.external-db.yml; unused in the packaged installation.

#The names changed on 14 September 2026 — read this if you already have a .env

These settings used to have a second set of names (NOTUSSHIP_SMTP_HOST and friends) that docker-compose.yml translated into the names the application reads. That translation is gone: whatever .env says now goes straight to the application.

The reason was a defect we hit ourselves. The translation only happened if compose was the thing starting the container. On a platform that injects environment variables directly — ours runs on Coolify — the old names were set, visible inside the container, and never read. No error, no log line, email silently off. The translation table was not even identical across the three compose files.

If old names are still in your .env, the application says so at startup:

warn: Configuration variable NOTUSSHIP_SMTP_HOST is NOT READ; the application
      reads NotusShip__Email__Host, which is EMPTY — this setting is off.

If you have already filled in the new name, the line drops to information and you can delete the old variable. Values are never logged, only names.

The full list of new names is in deploy/.env.example. One rule while you migrate: never leave a number or a true/false setting empty. A blank NotusShip__Email__Port= is not the same as an unset one — the application fails to start with Failed to convert configuration value ''. Write the value or comment the line out. (The old compose hid this behind ${…:-587}.)

#What skipping SMTP costs you

Email is optional and the product keeps working without it: invitation links are shown on screen and notifications stay inside the application. An installation that says "configure SMTP first" produces installations that are never finished — which is why it is not required.

But one thing does not work: password recovery. A user who forgets their password cannot reset it themselves; the way out is an operator command on the server:

docker compose exec app dotnet NotusShip.Api.dll reset-password <email>

The product says this on screen rather than letting you wait for a mail that will never arrive, and Settings → Members warns that email is not configured. Even so, the cheapest moment is to fill SMTP in during installation: the install finishes in eight seconds, so skipping this step is very easy and the cost shows up months later, at a moment nobody planned for.


#6. Common problems

docker compose up -d says unauthorized You are not signed in to the registry, or the token you were given has expired. Run the docker login ghcr.io command from section 1 and try again. The image is in a private registry; there is no anonymous access.

The log says password authentication failed for user "notusship" The database was created with a different password than the one now in .env. PostgreSQL writes the password only when it first creates its data directory, so correcting .env afterwards changes nothing. If the installation is still empty, the shortest way out is to start over:

docker compose down -v      # ⚠ DELETES the database volume
docker compose up -d

If it already holds data, do not use -v; change the password inside the database instead:

docker compose exec db psql -U notusship -c "ALTER USER notusship PASSWORD 'the-value-in-your-env';"
docker compose restart app

A user forgot their password and "forgot password" does not send a link SMTP is not configured on the installation. The product says so on screen. An operator resets the password:

docker compose exec app dotnet NotusShip.Api.dll reset-password <email>

The lasting fix is to fill in NotusShip__Email__Host and its siblings (section 5). Once you do and run docker compose up -d, the warning on Settings → Members disappears.

The application does not start and the log says required configuration is missing The message names each missing variable and how to generate it. Fill in your .env and run docker compose up -d.

The log says NOTUSSHIP_MODE was not recognised It has to be cloud or selfhosted. In the self-hosted package this value is already fixed; do not change it by hand.

Two error lines about __EFMigrationsHistory appear on first start That is normal. On an empty database Entity Framework queries this table first, fails to find it and logs the error; it then creates the tables immediately after. If you saw the line saying migrations were applied, nothing is wrong.

The log says backups will not run because of pg_dump The PostgreSQL client in the image is older than the database server. Upgrade the image. This check runs at start-up so that you learn about the problem now rather than at 02:00.

The sign-up screen says the installation is already set up Expected behaviour: registration closes after the first user. Create the account with the command in section 2.

I cannot connect to the database from outside Deliberate: the db service's port is not published. When you need it, use docker compose exec db psql -U notusship.

A banner appeared saying the license needs renewing Your license has expired. Within the 14-day grace period everything keeps working. What happens once the grace period ends depends on the size of your installation:

  • 3 users or fewer — the installation falls back to the free tier and keeps working normally. You do not need to remove the key from .env.
  • More than 3 users — writing stops; reading, signing in and backups stay on. You are never cut off from your data.

If your trial key expires and you are a small team, there is nothing to do: having taken a key never leaves you worse off than never having had one.

To see the state: docker compose exec app dotnet NotusShip.Api.dll license-show

It says all seats are taken You have reached the number of users in your license. Existing users are unaffected; only adding new ones stops.

Where are the logs docker compose logs app (JSON), and daily files in the notusship-logs volume for the last 7 days.


#7. Removal

docker compose down          # containers go, data stays
docker compose down -v       # THE DATA GOES TOO — back up first