Compare commits

..
7 Commits
Author SHA1 Message Date
Abdullah Atta 768384011d api: re-enable password change 2026-08-10 12:08:35 +05:00
Abdullah Atta a8e73069c2 common: avoid long lived mail client 2026-08-05 22:16:56 +05:00
Abdullah Atta 594f81a3b9 identity: log on email confirmation 2026-08-05 17:58:31 +05:00
Abdullah Atta 518e396079 common: fix NOTESNOOK_CORS_ORIGINS env var 2026-08-05 17:50:43 +05:00
Abdullah Atta d694ed2fee common: send message to subscriptions server on email confirmed 2026-08-05 13:42:49 +05:00
Abdullah Atta 13ab0ba039 sync: update date synced on item push 2026-08-05 13:42:33 +05:00
Ibaraki DoujiandGitHub 3c7327d3c7 docker: fix compose self-hosting and add external Mongo/MinIO/Garage examples (#106)
* docker: update .env and docker-compose.yml for SMTP and new services

* docker: add external MinIO, MongoDB, and GarageHQ examples with setup instructions

* docs: update README with dotenv docker setup instructions and configuration redirect for external services

* docker: update service dependency condition for setup-themes in docker-compose.yml

* chore: update example URLs in .env for public services
2026-07-29 09:07:52 +05:00
29 changed files with 754 additions and 130 deletions
+31 -9
View File
@@ -12,21 +12,26 @@ NOTESNOOK_API_SECRET=
# Possible values: true/false
DISABLE_SIGNUPS=false
### SMTP Configuration ###
# SMTP Configuration is required for sending emails for password reset, 2FA emails etc. You can get SMTP settings from your email provider.
### SMTP Configuration (optional) ###
# ================================================================================
# WARNING: If SMTP is not configured, email 2FA and password-reset emails will
# NOT work. Newly created users MUST change 2FA to an Authenticator app:
# Settings > Auth > Change 2FA method
# ================================================================================
# SMTP is used for sending emails for password reset, 2FA emails etc. You can get SMTP settings from your email provider.
# Description: Username for the SMTP connection (most time it is the email address of your account). Check your email provider's documentation to get the appropriate value.
# Required: yes
# Required: no
SMTP_USERNAME=
# Description: Password for the SMTP connection. Check your email provider's documentation to get the appropriate value.
# Required: yes
# Required: no
SMTP_PASSWORD=
# Description: Host on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
# Required: yes
# Required: no
# Example: smtp.gmail.com
SMTP_HOST=
# Description: Port on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
# Required: yes
# Required: no
# Example: 465
SMTP_PORT=
@@ -42,6 +47,7 @@ TWILIO_AUTH_TOKEN=
TWILIO_SERVICE_SID=
# Description: Add the origins for which you want to allow CORS. Leave it empty to allow all origins to access your server. If you want to allow multiple origins, seperate each origin with a comma.
# Note: this value is passed to the server as NOTESNOOK_CORS internally.
# Required: no
# Example: https://app.notesnook.com,http://localhost:3000
NOTESNOOK_CORS_ORIGINS=
@@ -58,16 +64,20 @@ KNOWN_PROXIES=
NOTESNOOK_APP_PUBLIC_URL=https://app.notesnook.com
# Description: This is the public URL for the monograph frontend.
# Required: yes
# Example: https://monogr.ph
# Example: https://monogr.yourdomain.com
MONOGRAPH_PUBLIC_URL=http://localhost:6264
# Description: This is the public URL for the Authentication server. Used for generating email confirmation & password reset URLs.
# Required: yes
# Example: https://auth.streetwriters.co
# Example: https://auth.yourdomain.com
AUTH_SERVER_PUBLIC_URL=http://localhost:8264
# Description: This is the public URL for the S3 attachments server (minio). It'll be used by the Notesnook clients for uploading/downloading attachments.
# Required: yes
# Example: https://attachments.notesnook.com
# Example: https://attachments.yourdomain.com
ATTACHMENTS_SERVER_PUBLIC_URL=http://localhost:9000
# Description: This is the public URL for the Notesnook sync API (browser-reachable; used by Monograph).
# Required: yes
# Example: https://api.yourdomain.com
NOTESNOOK_API_PUBLIC_URL=http://localhost:5264
# Description: Custom username for the root Minio account. Minio is used for storing your attachments. This must be greater than 3 characters in length.
# Required: no
@@ -75,3 +85,15 @@ MINIO_ROOT_USER=
# Description: Custom password for the root Minio account. Minio is used for storing your attachments. This must be greater than 8 characters in length.
# Required: no
MINIO_ROOT_PASSWORD=
# Optional: only needed when using `docker compose --profile extras up`
# Description: Public URL for the CORS proxy service
CORS_PROXY_PUBLIC_URL=http://localhost:3001
# Description: Allowed origins for the CORS proxy (* for all origins)
CORS_PROXY_ALLOWED_ORIGINS=*
# Description: Public URL for the Inbox API service
INBOX_API_PUBLIC_URL=http://localhost:5181
# Description: Public URL for the Themes server
THEMES_SERVER_PUBLIC_URL=http://localhost:9200
# Description: Git repository URL for the Themes server
THEMES_REPO_URL=https://github.com/streetwriters/notesnook-themes.git
+28 -29
View File
@@ -93,39 +93,38 @@ namespace Notesnook.API.Controllers
[HttpPatch("password/{type}")]
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
{
return BadRequest(new { error = "Password change is currently disabled." });
// var userId = User.GetUserId();
// var clientId = User.FindFirstValue("client_id");
// var jti = User.FindFirstValue("jti");
// var isPasswordReset = type == "reset";
// try
// {
// var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
// if (!result)
// return BadRequest("Failed to change password.");
var userId = User.GetUserId();
var clientId = User.FindFirstValue("client_id");
var jti = User.FindFirstValue("jti");
var isPasswordReset = type == "reset";
try
{
var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
if (!result)
return BadRequest("Failed to change password.");
// await UserService.SetUserKeysAsync(userId, form.UserKeys);
await UserService.SetUserKeysAsync(userId, form.UserKeys);
// await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
// await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
// {
// UserId = userId,
// OriginTokenId = jti,
// Message = new Message
// {
// Type = "logout",
// Data = JsonSerializer.Serialize(new { reason = "Password changed." })
// }
// });
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{
UserId = userId,
OriginTokenId = jti,
Message = new Message
{
Type = "logout",
Data = JsonSerializer.Serialize(new { reason = "Password changed." })
}
});
// return Ok();
// }
// catch (Exception ex)
// {
// logger.LogError(ex, "Failed to change password");
// return BadRequest(new { error = ex.Message });
// }
return Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to change password");
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("reset")]
+3 -3
View File
@@ -57,7 +57,7 @@ namespace Notesnook.API.Hubs
private ISyncItemsRepositoryAccessor Repositories { get; }
private SyncDeviceService SyncDeviceService { get; }
private readonly IUnitOfWork unit;
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string>> UpsertActionsMap;
private readonly CollectionDef[] BaseCollectionDefs;
private readonly CollectionDef[] V4CollectionDefs;
ILogger<SyncV2Hub> Logger { get; }
@@ -96,7 +96,7 @@ namespace Notesnook.API.Hubs
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
];
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string>> {
{ "settingitem", Repositories.Settings.UpsertMany },
{ "attachment", Repositories.Attachments.UpsertMany },
{ "note", Repositories.Notes.UpsertMany },
@@ -149,7 +149,7 @@ namespace Notesnook.API.Hubs
try
{
var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
UpsertItems(pushItem.Items, userId, 1);
UpsertItems(pushItem.Items, userId);
if (!await unit.Commit()) return 0;
@@ -55,24 +55,6 @@ namespace Notesnook.API.Repositories
return ALGORITHMS.Contains(algorithm);
}
public Task<long> CountItemsSyncedAfterAsync(string userId, long timestamp)
{
var filter = Builders<SyncItem>.Filter.And(Builders<SyncItem>.Filter.Gt("DateSynced", timestamp), Builders<SyncItem>.Filter.Eq("UserId", userId));
return Collection.CountDocumentsAsync(filter);
}
public Task<IAsyncCursor<SyncItem>> FindItemsSyncedAfter(string userId, long timestamp, int batchSize)
{
var filter = Builders<SyncItem>.Filter.And(Builders<SyncItem>.Filter.Gt("DateSynced", timestamp), Builders<SyncItem>.Filter.Eq("UserId", userId));
return Collection.FindAsync(filter, new FindOptions<SyncItem>
{
BatchSize = batchSize,
AllowDiskUse = true,
AllowPartialResults = false,
NoCursorTimeout = true,
Sort = new SortDefinitionBuilder<SyncItem>().Ascending("_id")
});
}
public Task<IAsyncCursor<SyncItem>> FindItemsById(string userId, IEnumerable<string> ids, bool all, int batchSize)
{
var filters = new List<FilterDefinition<SyncItem>>(new[] { Builders<SyncItem>.Filter.Eq("UserId", userId) });
@@ -94,41 +76,7 @@ namespace Notesnook.API.Repositories
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
}
public void Upsert(SyncItem item, string userId, long dateSynced)
{
if (item.Length > 15 * 1024 * 1024)
{
throw new Exception($"Size of item \"{item.ItemId}\" is too large. Maximum allowed size is 15 MB.");
}
if (!IsValidAlgorithm(item.Algorithm))
{
throw new Exception($"Invalid alg identifier {item.Algorithm}");
}
// Handle case where the cipher is corrupted.
if (!IsBase64String(item.Cipher))
{
logger.LogError("Corrupted item {ItemId} in collection {CollectionName}. Length: {Length}, Cipher: {Cipher}",
item.ItemId, this.collectionName, item.Length, item.Cipher);
throw new Exception($"Corrupted item \"{item.ItemId}\" in collection \"{this.collectionName}\". Please report this error to support@streetwriters.co.");
}
if (item.ItemId == null)
throw new Exception($"Item does not have an ItemId.");
item.DateSynced = dateSynced;
item.UserId = userId;
var filter = Builders<SyncItem>.Filter.And(
Builders<SyncItem>.Filter.Eq("UserId", userId),
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)
);
dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, filter, item, new ReplaceOptions { IsUpsert = true }, ct));
}
public void UpsertMany(IEnumerable<SyncItem> items, string userId, long dateSynced)
public void UpsertMany(IEnumerable<SyncItem> items, string userId)
{
var userIdFilter = Builders<SyncItem>.Filter.Eq("UserId", userId);
var writes = new List<WriteModel<SyncItem>>();
@@ -160,7 +108,7 @@ namespace Notesnook.API.Repositories
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)
);
item.DateSynced = dateSynced;
item.DateSynced = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
item.UserId = userId;
writes.Add(new ReplaceOneModel<SyncItem>(filter, item)
+11 -2
View File
@@ -55,17 +55,26 @@ dotnet run --project Streetwriters.Identity/Streetwriters.Identity.csproj
The sync server can easily be started using Docker.
Download `docker-compose.yml` and [`.env`](.env):
```bash
wget https://raw.githubusercontent.com/streetwriters/notesnook-sync-server/master/docker-compose.yml
wget https://raw.githubusercontent.com/streetwriters/notesnook-sync-server/master/.env
```
And then use Docker Compose to start the servers:
Edit `.env` and set at least `NOTESNOOK_API_SECRET`. See `.env` for all configuration options.
Then start the stack:
```bash
docker compose up
```
This takes care of setting up everything including MongoDB, Minio etc.
This sets up MongoDB, MinIO, and the Notesnook services.
Optional services (`themes-server`, `cors-proxy`, `inbox-api`): `docker compose --profile extras up`
For external MongoDB, MinIO, or Garage deployments, see [`examples/`](examples/).
## TODO Self-hosting
+14
View File
@@ -42,6 +42,20 @@ namespace Streetwriters.Common
PackageName = "com.streetwriters.notesnook",
OnEmailConfirmed = async (userId) =>
{
if (!Constants.IS_SELF_HOSTED)
{
await WampServers.SubscriptionServer.PublishMessageAsync(
SubscriptionServerTopics.EmailConfirmedTopic,
new EmailConfirmedMessage
{
UserId = userId,
ClientId = "notesnook",
AppId = ApplicationType.NOTESNOOK,
ConfirmedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
}
);
}
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{
UserId = userId,
+1 -1
View File
@@ -78,7 +78,7 @@ namespace Streetwriters.Common
public static string? SUBSCRIPTIONS_SERVER_HOST => ReadSecret("SUBSCRIPTIONS_SERVER_HOST");
public static string? SUBSCRIPTIONS_CERT_PATH => ReadSecret("SUBSCRIPTIONS_CERT_PATH");
public static string? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS")?.Split(",") ?? [];
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS_ORIGINS")?.Split(",") ?? [];
public static string? SIGNALR_REDIS_CONNECTION_STRING => ReadSecret("SIGNALR_REDIS_CONNECTION_STRING");
public static string MONOGRAPH_PUBLIC_URL => ReadSecret("MONOGRAPH_PUBLIC_URL") ?? "https://monogr.ph";
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
namespace Streetwriters.Common.Messages
{
public class EmailConfirmedMessage
{
[JsonPropertyName("userId")]
public required string UserId { get; set; }
[JsonPropertyName("clientId")]
public required string ClientId { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("confirmedAt")]
public long ConfirmedAt { get; set; }
}
}
+22 -25
View File
@@ -14,9 +14,8 @@ using Streetwriters.Common.Models;
namespace Streetwriters.Common.Services
{
public class EmailSender : IEmailSender, IAsyncDisposable
public class EmailSender : IEmailSender
{
private readonly SmtpClient mailClient = new();
private readonly ILogger<EmailSender> logger;
public EmailSender(ILogger<EmailSender> logger)
@@ -32,27 +31,25 @@ namespace Streetwriters.Common.Services
Dictionary<string, byte[]>? attachments = null
)
{
if (!mailClient.IsConnected)
using var mailClient = new SmtpClient();
if (int.TryParse(Common.Constants.SMTP_PORT, out int port))
{
if (int.TryParse(Common.Constants.SMTP_PORT, out int port))
{
await mailClient.ConnectAsync(
Common.Constants.SMTP_HOST,
port,
MailKit.Security.SecureSocketOptions.Auto
);
}
else
{
throw new InvalidDataException("SMTP_PORT is not a valid integer value.");
}
await mailClient.ConnectAsync(
Common.Constants.SMTP_HOST,
port,
MailKit.Security.SecureSocketOptions.Auto
);
}
else
{
throw new InvalidDataException("SMTP_PORT is not a valid integer value.");
}
if (!mailClient.IsAuthenticated)
await mailClient.AuthenticateAsync(
Common.Constants.SMTP_USERNAME,
Common.Constants.SMTP_PASSWORD
);
await mailClient.AuthenticateAsync(
Common.Constants.SMTP_USERNAME,
Common.Constants.SMTP_PASSWORD
);
var message = new MimeMessage();
message.From.Add(new MailboxAddress(from.DisplayName, from.Address));
@@ -70,6 +67,11 @@ namespace Streetwriters.Common.Services
);
await mailClient.SendAsync(message);
if (mailClient.IsConnected)
{
await mailClient.DisconnectAsync(true);
}
}
private async Task<MimeEntity> GetEmailBodyAsync(
@@ -129,10 +131,5 @@ namespace Streetwriters.Common.Services
}
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
await mailClient.DisconnectAsync(true);
mailClient.Dispose();
}
}
}
+1
View File
@@ -110,6 +110,7 @@ namespace Streetwriters.Common
public const string CreateSubscriptionTopic = "co.streetwriters.subscriptions.create";
public const string CreateSubscriptionV2Topic = "co.streetwriters.subscriptions.v2.create";
public const string DeleteSubscriptionTopic = "co.streetwriters.subscriptions.delete";
public const string EmailConfirmedTopic = "co.streetwriters.subscriptions.email_confirmed";
}
public struct IdentityServerTopics
@@ -116,6 +116,7 @@ namespace Streetwriters.Identity.Controllers
if (await UserManager.IsInRoleAsync(user, client.Id) && client.OnEmailConfirmed != null)
{
logger.LogInformation("Email confirmed for user {UserId} on client {ClientId}. Triggering OnEmailConfirmed callback.", userId, client.Id);
await client.OnEmailConfirmed(userId);
}
+109 -7
View File
@@ -25,14 +25,11 @@ services:
"INSTANCE_NAME"
"NOTESNOOK_API_SECRET"
"DISABLE_SIGNUPS"
"SMTP_USERNAME"
"SMTP_PASSWORD"
"SMTP_HOST"
"SMTP_PORT"
"AUTH_SERVER_PUBLIC_URL"
"NOTESNOOK_APP_PUBLIC_URL"
"MONOGRAPH_PUBLIC_URL"
"ATTACHMENTS_SERVER_PUBLIC_URL"
"NOTESNOOK_API_PUBLIC_URL"
)
# Check each required environment variable
@@ -43,6 +40,23 @@ services:
fi
done
if [ -z "$$SMTP_HOST" ] || [ -z "$$SMTP_PORT" ] || [ -z "$$SMTP_USERNAME" ] || [ -z "$$SMTP_PASSWORD" ]; then
cat <<'EOF'
================================================================================
WARNING: SMTP IS NOT CONFIGURED
================================================================================
Email-based 2FA codes and password-reset emails will NOT be sent.
Newly created users MUST switch 2FA to an Authenticator app:
Settings > Auth > Change 2FA method
Configure SMTP_* in .env if you need email-based 2FA or password reset.
================================================================================
EOF
fi
echo "All required environment variables are set."
# Ensure the validate service runs first
restart: "no"
@@ -183,16 +197,104 @@ services:
networks:
- notesnook
healthcheck:
test: wget --tries=1 -nv -q http://localhost:3000/api/health -O- || exit 1
test: >
bun -e "fetch('http://127.0.0.1:3000/api/health')
.then(r => { if (!r.ok) process.exit(1); })
.catch(() => process.exit(1))"
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
environment:
<<: *server-discovery
API_HOST: http://notesnook-server:5264
NODE_ENV: production
HOST: 0.0.0.0
API_HOST: ${NOTESNOOK_API_PUBLIC_URL:-http://localhost:5264}
PUBLIC_URL: ${MONOGRAPH_PUBLIC_URL}
setup-themes:
profiles: [extras]
image: busybox:latest
networks:
- notesnook
volumes:
- themesdata:/data
command: ['sh', '-c', 'if [ ! -f /data/installs.json ]; then echo "{}" > /data/installs.json; fi']
themes-server:
profiles: [extras]
image: streetwriters/themes-server:latest
ports:
- 9200:9000
networks:
- notesnook
depends_on:
setup-themes:
condition: service_completed_successfully
healthcheck:
test: >
bun -e "fetch('http://127.0.0.1:9000/health')
.then(r => { if (!r.ok) process.exit(1); })
.catch(() => process.exit(1))"
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
environment:
HOST: 0.0.0.0
PORT: 9000
THEMES_REPO_URL: ${THEMES_REPO_URL:-https://github.com/streetwriters/notesnook-themes.git}
volumes:
- type: volume
source: themesdata
target: /installs.json
volume:
subpath: installs.json
cors-proxy:
profiles: [extras]
image: streetwriters/cors-proxy:latest
ports:
- 3001:3000
networks:
- notesnook
healthcheck:
test: >
bun -e "fetch('http://127.0.0.1:3000/health')
.then(r => { if (!r.ok) process.exit(1); })
.catch(() => process.exit(1))"
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
environment:
HOST: 0.0.0.0
PORT: 3000
ALLOWED_ORIGINS: ${CORS_PROXY_ALLOWED_ORIGINS:-*}
inbox-api:
profiles: [extras]
image: streetwriters/notesnook-inbox:latest
ports:
- 5181:5181
networks:
- notesnook
depends_on:
- notesnook-server
healthcheck:
test: >
bun -e "fetch('http://127.0.0.1:5181/health')
.then(r => { if (!r.ok) process.exit(1); })
.catch(() => process.exit(1))"
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
environment:
HOST: 0.0.0.0
PORT: 5181
NOTESNOOK_API_SERVER_URL: http://notesnook-server:5264
autoheal:
image: willfarrell/autoheal:latest
tty: true
@@ -212,4 +314,4 @@ networks:
volumes:
dbdata:
s3data:
themesdata:
+50
View File
@@ -0,0 +1,50 @@
# Docker Compose Examples
Reference deployments that split infrastructure from the Notesnook stack. Each example overrides the root [`docker-compose.yml`](../docker-compose.yml) instead of duplicating it.
## Pattern
Run all commands from the **repository root**.
| Step | Compose files | Role |
|------|---------------|------|
| 1. Infra | `examples/<example>/infra.compose.yml` | MongoDB, MinIO, or Garage on `notesnook-shared` |
| 2. Notesnook | `docker-compose.yml` + `examples/<example>/notesnook.override.yml` | Disables embedded services; patches connection strings |
```bash
# 1. Merge example vars into root .env (see examples/<example>/.env.example)
# 2. Start infrastructure
docker compose -f examples/<example>/infra.compose.yml up -d
# 3. Start Notesnook (one-time setup for garage — see example README)
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml up -d
# Optional extras
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml --profile extras up -d
# Tear down (reverse order)
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml down
docker compose -f examples/<example>/infra.compose.yml down
```
## Environment
The root [`.env`](../.env) is loaded automatically when running from the repository root. Each example ships a **delta-only** [`.env.example`](external-mongodb/.env.example) listing only variables that differ — merge those lines into your root `.env`.
Infra-only variables (e.g. `GARAGE_RPC_SECRET`) are in `infra.env.example` where applicable; pass with `--env-file` or merge into root `.env`.
## Examples
| Example | External dependency | Notes |
|---------|---------------------|-------|
| [external-mongodb](external-mongodb/) | MongoDB | Embedded MinIO stays enabled |
| [external-minio](external-minio/) | MinIO | Embedded MongoDB stays enabled |
| [garage](garage/) | [GarageHQ](https://garagehq.deuxfleurs.fr/) S3 | Manual bucket setup via `setup-garage.sh` |
## Shared network
Infra compose files create **`notesnook-shared`**. Override files attach Notesnook services to it as an external network. Run only one infra example at a time on the same host.
## Root compose
The all-in-one stack: [`docker-compose.yml`](../docker-compose.yml) (no override file).
+6
View File
@@ -0,0 +1,6 @@
# Merge these into the repository root .env (see /.env for all other variables).
#
# MINIO_ROOT_USER and MINIO_ROOT_PASSWORD must match examples/external-minio/infra.env.example
# when starting the external MinIO infra stack.
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
+43
View File
@@ -0,0 +1,43 @@
# External MinIO
MinIO runs in a separate compose project. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml), which disables embedded `notesnook-s3` / `setup-s3` and points at external MinIO. Embedded MongoDB is unchanged.
## Setup
From the **repository root**:
```bash
# Ensure MINIO_* in root .env match infra.env.example
# Merge notesnook delta if needed (see .env.example)
# 1. MinIO + bucket setup
docker compose -f examples/external-minio/infra.compose.yml \
--env-file examples/external-minio/infra.env.example up -d
# Wait for notesnook-minio-setup to complete
docker compose -f examples/external-minio/infra.compose.yml ps -a
# 2. Notesnook
docker compose -f docker-compose.yml -f examples/external-minio/notesnook.override.yml up -d
```
## Environment
| File | Purpose |
|------|---------|
| [`infra.env.example`](infra.env.example) | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` for infra stack |
| [`.env.example`](.env.example) | Notesnook delta (credentials must match infra) |
Merge `MINIO_*` into root [`.env`](../../.env) so the sync server uses the same credentials.
## Stop
```bash
docker compose -f docker-compose.yml -f examples/external-minio/notesnook.override.yml down
docker compose -f examples/external-minio/infra.compose.yml down
```
## Notes
- Bucket `attachments` is created by `notesnook-minio-setup` on first infra start.
- `S3_INTERNAL_SERVICE_URL` is overridden to `http://notesnook-minio:9000`.
+47
View File
@@ -0,0 +1,47 @@
name: notesnook-external-minio-infra
services:
notesnook-minio:
image: minio/minio:RELEASE.2024-07-29T22-14-52Z
hostname: notesnook-minio
ports:
- 9000:9000
networks:
- notesnook-shared
volumes:
- s3data:/data/s3
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BROWSER: "on"
command: server /data/s3 --console-address :9090
healthcheck:
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
notesnook-minio-setup:
image: minio/mc:RELEASE.2024-07-26T13-08-44Z
depends_on:
notesnook-minio:
condition: service_healthy
networks:
- notesnook-shared
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
command:
- /bin/bash
- -c
- |
mc alias set minio http://notesnook-minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin}
mc mb minio/attachments -p
networks:
notesnook-shared:
name: notesnook-shared
volumes:
s3data:
@@ -0,0 +1,2 @@
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
@@ -0,0 +1,20 @@
services:
notesnook-s3:
profiles: [embedded-disabled]
setup-s3:
profiles: [embedded-disabled]
notesnook-server:
networks:
- notesnook
- notesnook-shared
depends_on: !override
identity-server:
condition: service_started
environment:
S3_INTERNAL_SERVICE_URL: "http://notesnook-minio:9000"
networks:
notesnook-shared:
external: true
+4
View File
@@ -0,0 +1,4 @@
# Merge these into the repository root .env (see /.env for all other variables).
MONGODB_CONNECTION_STRING_IDENTITY=mongodb://notesnook-mongodb:27017/identity?replSet=rs0
MONGODB_CONNECTION_STRING=mongodb://notesnook-mongodb:27017/?replSet=rs0
+36
View File
@@ -0,0 +1,36 @@
# External MongoDB
MongoDB runs in a separate compose project. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml), which disables the embedded `notesnook-db` service and points at external Mongo. Embedded MinIO is unchanged.
## Setup
From the **repository root**:
```bash
# Merge into root .env
cat examples/external-mongodb/.env.example >> .env
# set NOTESNOOK_API_SECRET and other required root .env values
# 1. MongoDB
docker compose -f examples/external-mongodb/infra.compose.yml up -d
# 2. Notesnook
docker compose -f docker-compose.yml -f examples/external-mongodb/notesnook.override.yml up -d
```
## Environment (delta)
See [`.env.example`](.env.example) — only `MONGODB_CONNECTION_STRING*` overrides are needed beyond the root `.env`.
## Stop
```bash
docker compose -f docker-compose.yml -f examples/external-mongodb/notesnook.override.yml down
docker compose -f examples/external-mongodb/infra.compose.yml down
```
## Notes
- Replica set `rs0` is required; the infra healthcheck initializes it.
- Host port `27017` is exposed for debugging.
- If infra stops, `identity-server` and `notesnook-server` fail until MongoDB is back.
@@ -0,0 +1,26 @@
name: notesnook-external-mongodb-infra
services:
notesnook-mongodb:
image: mongo:7.0.12
hostname: notesnook-mongodb
ports:
- 27017:27017
volumes:
- dbdata:/data/db
networks:
- notesnook-shared
command: --replSet rs0 --bind_ip_all
healthcheck:
test: echo 'try { rs.status() } catch (err) { rs.initiate() }; db.runCommand("ping").ok' | mongosh mongodb://localhost:27017 --quiet
interval: 40s
timeout: 30s
retries: 3
start_period: 60s
networks:
notesnook-shared:
name: notesnook-shared
volumes:
dbdata:
@@ -0,0 +1,31 @@
services:
notesnook-db:
profiles: [embedded-disabled]
identity-server:
depends_on: !override
validate:
condition: service_completed_successfully
networks:
- notesnook
- notesnook-shared
environment:
MONGODB_CONNECTION_STRING: ${MONGODB_CONNECTION_STRING_IDENTITY}
notesnook-server:
networks:
- notesnook
- notesnook-shared
depends_on: !override
notesnook-s3:
condition: service_started
setup-s3:
condition: service_started
identity-server:
condition: service_started
environment:
MONGODB_CONNECTION_STRING: ${MONGODB_CONNECTION_STRING}
networks:
notesnook-shared:
external: true
+6
View File
@@ -0,0 +1,6 @@
# Merge these into the repository root .env (see /.env for all other variables).
# Fill after running examples/garage/setup-garage.sh
GARAGE_ACCESS_KEY_ID=
GARAGE_ACCESS_KEY_SECRET=
ATTACHMENTS_SERVER_PUBLIC_URL=http://localhost:3900
+57
View File
@@ -0,0 +1,57 @@
# GarageHQ S3
[Garage](https://garagehq.deuxfleurs.fr/) replaces embedded MinIO. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml). MongoDB stays embedded.
Based on [PR #79](https://github.com/streetwriters/notesnook-sync-server/pull/79), with community review adjustments:
- Notesnook uses **presigned URLs** — no public bucket policy required.
- Do **not** use `aws s3api put-bucket-policy` for public read ([Garage S3 compatibility](https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/)).
- Use port **3900** (S3 API) for `ATTACHMENTS_SERVER_PUBLIC_URL` when not behind a reverse proxy.
## Setup
From the **repository root**:
```bash
# 1. Generate RPC secret and add to root .env or use --env-file
openssl rand -hex 32
# add GARAGE_RPC_SECRET=... to .env (see infra.env.example)
# 2. Garage infra
docker compose -f examples/garage/infra.compose.yml up -d
# 3. One-time bucket/key setup (from repo root)
./examples/garage/setup-garage.sh
# 4. Merge keys into root .env (see .env.example)
# 5. Notesnook
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d
```
`notesnook-server` may stay unhealthy until valid `GARAGE_ACCESS_KEY_*` values are in root `.env`:
```bash
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d --force-recreate notesnook-server
```
## Environment
| File | Purpose |
|------|---------|
| [`infra.env.example`](infra.env.example) | `GARAGE_RPC_SECRET` for infra |
| [`.env.example`](.env.example) | `GARAGE_ACCESS_KEY_*` and `ATTACHMENTS_SERVER_PUBLIC_URL` (merge into root `.env`) |
## Stop
```bash
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml down
docker compose -f examples/garage/infra.compose.yml down
```
## Manual setup
If `setup-garage.sh` fails, follow PR #79 layout/bucket/key steps manually. Skip public bucket policy. Optional bucket alias only for Garage web port 3902.
## MongoDB
This example only externalizes S3. For external MongoDB, combine with [external-mongodb](../external-mongodb/) or use the root all-in-one compose.
+21
View File
@@ -0,0 +1,21 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
consistency_mode = "consistent"
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
s3_region = "us-east-1"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
[admin]
api_bind_addr = "[::]:3903"
+31
View File
@@ -0,0 +1,31 @@
name: notesnook-garage-infra
services:
notesnook-garage:
image: dxflrs/garage:v2.3.0
hostname: notesnook-garage
restart: unless-stopped
ports:
- 3900:3900
- 3901:3901
- 3902:3902
networks:
- notesnook-shared
volumes:
- s3data:/var/lib/garage
- ./garage.toml:/etc/garage.toml:ro
environment:
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET}
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
notesnook-shared:
name: notesnook-shared
volumes:
s3data:
+2
View File
@@ -0,0 +1,2 @@
# Generate with: openssl rand -hex 32
GARAGE_RPC_SECRET=
+22
View File
@@ -0,0 +1,22 @@
services:
notesnook-s3:
profiles: [embedded-disabled]
setup-s3:
profiles: [embedded-disabled]
notesnook-server:
networks:
- notesnook
- notesnook-shared
depends_on: !override
identity-server:
condition: service_started
environment:
S3_INTERNAL_SERVICE_URL: "http://notesnook-garage:3900"
S3_ACCESS_KEY_ID: "${GARAGE_ACCESS_KEY_ID}"
S3_ACCESS_KEY: "${GARAGE_ACCESS_KEY_SECRET}"
networks:
notesnook-shared:
external: true
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
BUCKET="${GARAGE_BUCKET:-attachments}"
ZONE="${GARAGE_ZONE:-us-east-1}"
CAPACITY="${GARAGE_CAPACITY:-10G}"
KEY_NAME="${GARAGE_KEY_NAME:-notesnook-key}"
ENDPOINT="${GARAGE_S3_ENDPOINT:-http://127.0.0.1:3900}"
echo -e "${GREEN}--- Notesnook Garage setup ---${NC}"
CONTAINER_NAME="${GARAGE_CONTAINER_NAME:-}"
if [ -z "$CONTAINER_NAME" ]; then
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'notesnook-garage|garage' | head -n 1 || true)
fi
if [ -z "$CONTAINER_NAME" ]; then
echo -e "${RED}Error: no running Garage container found.${NC}"
echo "Start infra first (from repo root): docker compose -f examples/garage/infra.compose.yml up -d"
exit 1
fi
echo -e "Using container: ${GREEN}${CONTAINER_NAME}${NC}"
echo -e "\n${YELLOW}[1/5] Layout${NC}"
NODE_ID=""
for _ in $(seq 1 10); do
NODE_ID=$(docker exec "$CONTAINER_NAME" /garage node id -q | cut -d '@' -f 1 || true)
[ -n "$NODE_ID" ] && break
sleep 2
done
if [ -z "$NODE_ID" ]; then
echo -e "${RED}Error: could not read Garage node id.${NC}"
exit 1
fi
echo "Node ID: $NODE_ID"
docker exec "$CONTAINER_NAME" /garage layout assign -z "$ZONE" -c "$CAPACITY" "$NODE_ID"
echo "yes" | docker exec -i "$CONTAINER_NAME" /garage layout apply --version 1
echo -e "\n${YELLOW}[2/5] Bucket${NC}"
docker exec "$CONTAINER_NAME" /garage bucket create "$BUCKET" || true
if [ -n "${GARAGE_BUCKET_ALIAS:-}" ]; then
echo "Aliasing bucket to ${GARAGE_BUCKET_ALIAS} (optional website access)"
docker exec "$CONTAINER_NAME" /garage bucket alias "$BUCKET" "$GARAGE_BUCKET_ALIAS"
fi
echo -e "\n${YELLOW}[3/5] API key${NC}"
KEY_INFO=$(docker exec "$CONTAINER_NAME" /garage key create "$KEY_NAME")
echo "$KEY_INFO"
KEY_ID=$(echo "$KEY_INFO" | awk '/Key ID:/ {print $3}')
SECRET_KEY=$(echo "$KEY_INFO" | awk '/Secret key:/ {print $3}')
if [ -z "$KEY_ID" ] || [ -z "$SECRET_KEY" ]; then
echo -e "${RED}Error: failed to parse key id/secret from garage output.${NC}"
exit 1
fi
echo -e "\n${YELLOW}[4/5] Bucket permissions${NC}"
docker exec "$CONTAINER_NAME" /garage bucket allow "$BUCKET" --read --write --owner --key "$KEY_ID"
echo -e "\n${YELLOW}[5/5] CORS (aws-cli)${NC}"
docker run --rm \
--env AWS_ACCESS_KEY_ID="$KEY_ID" \
--env AWS_SECRET_ACCESS_KEY="$SECRET_KEY" \
--add-host=host.docker.internal:host-gateway \
amazon/aws-cli \
--endpoint-url "$ENDPOINT" \
s3api put-bucket-cors --bucket "$BUCKET" --cors-configuration '{
"CORSRules": [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
]
}'
cat <<EOF
${GREEN}--- Setup complete ---${NC}
Merge these into the repository root .env (see examples/garage/.env.example):
GARAGE_ACCESS_KEY_ID=$KEY_ID
GARAGE_ACCESS_KEY_SECRET=$SECRET_KEY
ATTACHMENTS_SERVER_PUBLIC_URL=$ENDPOINT
Notes:
- Notesnook uses presigned URLs for attachments; public bucket policies are not required.
- Use the S3 API URL (port 3900) for ATTACHMENTS_SERVER_PUBLIC_URL unless you proxy it.
- Do not use aws s3api put-bucket-policy for public read; Garage does not support this like MinIO.
Then restart the sync server (from repo root):
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d --force-recreate notesnook-server
EOF