Add ability to use docker secrets in Joplin server

Feature Request

As a user bringing up a Joplin server Docker container, I'd like the ability to set paths to secrets containing sensitive data such as user credentials, in environment variables in docker-compose.yml, so that I don't need to store plain text passwords and usernames inside my docker-compose.yml.

Disclaimer

I am not a security expert by any stretch of the imagination. My knowledge of attach vectors and best practices regarding security, storing user credentials etc. is fairly limited. However, I'm fairly confident that my hesitance to store plain text credentials in files I'd like to commit into version control is a bad idea.

I have made a suggestion for a change to the codebase that I believe would implement the base of my feature request. I have never touched Typescript in my life (except in the snippets below) and so I could be missing something obvious that makes this problem less trivial to solve than I think. This is also the primary reason why I didn't just submit a pull request via github.

Context

I'm setting up a new self hosted Joplin server using Docker. All of my server setup scripts and configs are stored in a (private) github repo that allows me to keep all my server config easily backed up and version controlled.

When setting up the Joplin server via Docker, I don't really like how database and mailer credentials are required to be stored in docker-compose.yml in plain text.

What I don't want to do:

  • Store passwords, tokens or secrets in plain text in any files I need to commit
  • Commit them as blanked out, and then have to go populate all of them after I clone the repo (this would basically guarantee the scenario of me forgetting to clear passwords before committing...)

The postgres credentials could be argued are less of a big deal since the connection is isolated to between the two containers, and I'd always randomly generate a new unique set of credentials purely for this connection anyway. It still feels not good though.

Storing mailer auth credentials in the clear feels a bit more dangerous. Although I can get a unique set of gmail smpt credentials for this particular application, if someone somehow got hold of these creds, I'd like them not to be able to do email stuff on my behalf.

What I do want to do:

  • Use docker secrets
  • Add a .gitignore catch for anything under .secrets, and never commit them
  • Store the secrets with appropriate ownership and access permissions
  • Allow Joplin server to take in a bunch of credential file paths in place of the raw credentials themselves

I have an example docker-compose.yml below. I've trimmed out some of the less relevant config, but the example should allow me to illustrate my envisaged usage.

# docker-compose.yml

version: '3.9'

services:
  joplin-db:
    image: postgres:15
    environment:
      - POSTGRES_DB_FILE=/run/secrets/joplin_db_name
      - POSTGRES_USER_FILE=/run/secrets/joplin_db_username
      - POSTGRES_PASSWORD_FILE=/run/secrets/joplin_db_password
    secrets:
      - postgres_db_name
      - postgres_username
      - postgres_password
    volumes:
      - joplin-db:/var/lib/postgresql/data

  joplin:
    container_name: joplin
    image: joplin/server:latest
    depends_on:
      - joplin-db
    environment:
      - APP_PORT=22300
      - APP_BASE_URL=https://example.com
      - STORAGE_DRIVER=Type=Filesystem; Path=/home/joplin/storage
      - DB_CLIENT=pg
      - POSTGRES_DATABASE_FILE=/run/secrets/postgres_db_name
      - POSTGRES_USER_FILE=/run/secrets/postgres_username
      - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
      - POSTGRES_PORT=5432
      - POSTGRES_HOST=joplin-db
      - MAILER_ENABLED=1
      - MAILER_HOST=smtp.gmail.com
      - MAILER_PORT=465
      - MAILER_SECURE=1
      - MAILER_AUTH_USER_FILE=/run/secrets/gmail_smtp_username
      - MAILER_AUTH_PASSWORD_FILE=/run/secrets/gmail_smtp_token
      - MAILER_NOREPLY_NAME=Joplin
      - MAILER_NOREPLY_EMAIL=admin@example.com
    secrets:
      - postgres_db_name
      - postgres_username
      - postgres_password
      - gmail_smtp_username
      - gmail_smtp_token
    volumes:
      - joplin-storage:/home/joplin/storage

volumes:
  joplin-db:
    external: true
  joplin-storage:
    external: true

secrets:
  postgres_db_name:
    file: ~/.secrets/joplin_db_name
  postgres_username:
    file: ~/.secrets/joplin_db_username
  postgres_password:
    file: ~/.secrets/joplin_db_password
  gmail_smtp_username:
    file: ~/.secrets/mail_smtp_username
  gmail_smtp_token:
    file: ~/.secrets/mail_smtp_token

Note that it's completely safe for me to paste this configuration here, since there's no chance I've forgotten to strip out any prod credentials before doing so :slight_smile:

It's also worth noting that support for this usage pattern seems pretty common and in fact the postgres image already supports the use of the *_FILE environment variable already (I'd have to check if postgres v15 supports this though).

One further note is that it seems not very widely known that docker secrets can be used without using a docker swarm, however, docker compose will indeed handle the above example just fine on its own.

Solution Space

Looking through the Joplin server code, it seems like it would be pretty straight forward to implement this functionality:

In env.ts::defaultEnvValues() add the following:

POSTGRES_DATABASE_FILE: '',
POSTGRES_USER_FILE: '',
POSTGRES_PASSWORD_FILE: '',

and

MAILER_AUTH_USER_FILE: '',
MAILER_AUTH_PASSWORD_FILE: ''

Then in config.ts add a check in databaseConfigFromEnv() and mailerConfigFromEnv() for the appropriate config fields to see if the *_FILE version of that environment variable is set, and if so try to read the contents of the file path stored in the environement variable, otherwise, use the regular environment variable as before.

Something like this:

function getEnvVarFromFile(path, fallback) {
	if (path.length == 0) {
		return fallback;
	}
	let envValue = fallback;
	try {
		const fileContents = readFileSync(path, 'utf-8');
		if (fileContents.length > 0) {
			envValue = fileContents;
		}
	} catch (error) {
		// Warn here maybe?
	}
	return envValue;
}

function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): DatabaseConfig {
	// ... Code ommited for clarity
	if (env.DB_CLIENT === 'pg') {
		// ... Code ommited for clarity

			return {
				...databaseConfig,
				name: getEnvVarFromFile(env.POSTGRES_DATABASE_FILE, env.POSTGRES_DATABASE),
				user: getEnvVarFromFile(env.POSTGRES_USER_FILE, env.POSTGRES_USER),
				password: getEnvVarFromFile(env.POSTGRES_PASSWORD_FILE, env.POSTGRES_PASSWORD),
				port: env.POSTGRES_PORT,
				host: databaseHostFromEnv(runningInDocker, env) || 'localhost',
			};
		}
	}

	// ... Code ommited for clarity
}

function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
	return {
		enabled: env.MAILER_ENABLED,
		host: env.MAILER_HOST,
		port: env.MAILER_PORT,
		security: env.MAILER_SECURITY,
		authUser: getEnvVarFromFile(env.MAILER_AUTH_USER_FILE, env.MAILER_AUTH_USER),
		authPassword: getEnvVarFromFile(env.MAILER_AUTH_PASSWORD_FILE, env.MAILER_AUTH_PASSWORD),
		noReplyName: env.MAILER_NOREPLY_NAME,
		noReplyEmail: env.MAILER_NOREPLY_EMAIL,
	};
}

Remarks

I feel like this feature is definitely worth adding. To my knowledge it should be a fairly safe and isolated change to make and will result in a more secure experience for anyone standing up a Joplin server instance using Docker's compose methods.

What do you think?

2 Likes

@jmdejoanelli welcome to the forum.

Currently you can remove the environment variables and put them in a separate environment file, say, .env.

Then, assuming that the .env file is in the same directory as the docker-compose.yml file, in the app section of the docker-compose.yml file add:

env_file: .env

You can then gitignore .env.

Example

...
    app:
        restart: unless-stopped
        image: joplin/server:latest
        depends_on:
            - db
        ports:
            - "22300:22300"
        env_file: .env
...

Your .env file would then look something like:

APP_NAME=Joplin Server
APP_BASE_URL=https://joplin.example.com
APP_PORT=22300
POSTGRES_PASSWORD=secretpostgrespass
POSTGRES_DATABASE=joplin
POSTGRES_USER=joplin
POSTGRES_PORT=5432
POSTGRES_HOST=db
DB_CLIENT=pg
MAILER_ENABLED=1
MAILER_HOST=smtp.example.com
MAILER_PORT=465
MAILER_SECURITY=tls
MAILER_AUTH_USER=mailacc@example.com
MAILER_AUTH_PASSWORD=secretSMTPpassword
MAILER_NOREPLY_NAME=JoplinServer
MAILER_NOREPLY_EMAIL=noreply@example.com

Would that help?

That would certainly work, however the .env solution is only half way to what I'd like to see.

Is it possible to have a hybrid setup where I set only the credentials in the .env file and then set all the other non sensitive settings in docker-compose.yml as environment variables? That way I could treat the entire .env file as a secret. If it contains all the non-sensitive config however, I'd have to rewrite that anytime I need to rebuild/reinstance my server, which doesn't bode well for my gold fish memory and my goal to automate server bring up as much as possible.

I still think supporting secrets would be a useful feature to have. As it unifies credential handling across most containers and semi-automates secret management (via Docker secrets).

I forgot to mention as well, that I'd still need to store the postgres credentials in two different places using the .env method. Once in the docker secret files, and once in the .env file for Joplin.

I.e. splitting the credentials out into their own secrets also makes sharing secrets among containers slightly cleaner, and the credentials always only exist in one place which makes them easier to keep track of and secure.

I'm having the same issue after trying several work arounds.

In order to implement what @jmdejoanelli is describing, this file would need to be updated: https://github.com/laurent22/joplin/blob/dev/packages/server/src/env.ts#L217

Specifically, the parseEnv file would need to be updated.

This file ends up in the docker image at /home/joplin/packages/server/dist/env.js.

I may decide to just write my own version of that file and deploy it to the container.

@crchauffe welcome to the forum.

I have only just started experimenting with this but it appears that you can have a "hybrid". You can put some environment settings in the docker compose file and have the secrets replaced by variables which have their values defined in a .env file. As long as the same variable names are used (such as ${POSTGRES_DATABASE}*) they can be applied to both the app and the db with just one entry.

* The Postgres database name environment setting in the db section is POSTGRES_DB. The same setting in the app section is called POSTGRES_DATABASE. So I chose to call the variable ${POSTGRES_DATABASE} and applied it to both POSTGRES_DB and POSTGRES_DATABASE.

docker-compose example

version: "3"
services:
  db:
    restart: unless-stopped
    image: postgres:16.1
    ports:
      - 5432:5432
    volumes:
      - ./data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_DB=${POSTGRES_DATABASE}
  app:
    restart: unless-stopped
    image: joplin/server:latest
    depends_on:
      - db
    ports:
      - 22300:22300
    environment:
      - APP_NAME=Joplin
      - APP_BASE_URL=https://example.com
      - APP_PORT=22300
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DATABASE=${POSTGRES_DATABASE}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PORT=5432
      - POSTGRES_HOST=db
      - DB_CLIENT=pg
      - MAILER_ENABLED=1
      - MAILER_HOST=smtp.example.com
      - MAILER_PORT=465
      - MAILER_SECURITY=tls
      - MAILER_AUTH_USER=${MAILER_AUTH_USER}
      - MAILER_AUTH_PASSWORD=${MAILER_AUTH_PASSWORD}
      - MAILER_NOREPLY_NAME=Joplin
      - MAILER_NOREPLY_EMAIL=admin@example.com

.env example

POSTGRES_PASSWORD=add_postgres_supersecretpassword
POSTGRES_DATABASE=add_database_name
POSTGRES_USER=add_database_user
MAILER_AUTH_USER=login@example.com
MAILER_AUTH_PASSWORD=add_smtp_password

I have not got to the stage yet of seeing if the .env file can be located anywhere else other than with the compose file.

The problem with using a .env file to store secrets is the .env is unencrypted as it sits on the harddrive (data at rest). If a malicious actor were to gain access to the .env file, they would be able to access the secret.

The alternative is to use Docker secrets which stores the secret that can be referenced by name, but the data is stored in an encrypted state both at rest and in transit into the container.

In order to do this through Docker, you have to be using Docker in swarm-mode which you can enable with: docker swarm init

Once that's done, you can create the secret using:

echo "my_super_secret_password" | docker secret create joplin_postgres_password -

Note: Don't forget the dash (-) at the end of the line!

When that executes, you can see the secret is created by running docker secret ls:

$ echo "my_super_secret_password" | docker secret create joplin_postgres_password -
jq816rzrdfqjsmntroh4p3r64

$ docker secret ls
ID                          NAME                           DRIVER    CREATED         UPDATED
jq816rzrdfqjsmntroh4p3r64   joplin_postgres_password                 9 seconds ago   9 seconds ago

Next, you'd update your docker compose with the following:

version: "3"
services:
  db:
    restart: unless-stopped
    image: postgres:latest
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/joplin_postgres_password
      - POSTGRES_USER=joplin
      - POSTGRES_DB=joplin
    secrets:
      - joplin_postgres_password

  app:
    restart: unless-stopped
    image: joplin/server:latest
    depends_on:
      - db
    ports:
      - 22300:22300
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/joplin_postgres_password
      - POSTGRES_DATABASE=joplin
      - POSTGRES_USER=joplin
      - POSTGRES_PORT=5432
      - POSTGRES_HOST=db
      - DB_CLIENT=pg
    secrets:
      - joplin_postgres_password

secrets:
  joplin_postgres_password:
    external: true

Just in case it's unclear, here's what I've changed in the docker-compose:

  • Changed the POSTGRES_PASSWORD environment variable to POSTGRES_PASSWORD_FILE
  • Set the value of POSTGRES_PASSWORD_FILE to /run/secrets/joplin_postgres_password.
  • Added the secrets attribute to each service and added joplin_postgres_password to indicate to Docker that the services require access to the joplin_postgres_password secret.
  • Added the secrets attribute to the docker-compose file and added joplin_postgres_password to indicate to Docker that the whole docker-compose file needs access to the joplin_postgres_password secret.

Per the Docker docs on secrets, when a service that has access to a secret is spun up, Docker deploys a file with the same name of the secret to /run/secrets/ which contains the value of the secret.

Since the Joplin Server Docker image doesn't support the _FILE convention, I've tried a work-around by configuring the docker-compose file to use my custom entry point file which implements the _FILE convention for the POSTGRES_PASSWORD.

This is my custom entry point file:

#!/usr/bin/bash

# POSTGRES_PASSWORD_FILE='/run/secrets/joplin_postgres_password'

# Update the POSTGRES_PASSWORD environment variable to be the contents of the secrets file, if it exists
if [[ -f $POSTGRES_PASSWORD_FILE ]]; then
    POSTGRES_PASSWORD=`cat $POSTGRES_PASSWORD_FILE`
    export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"

# else
#     echo "Secret file not found.  Was expecting to find one at $POSTGRES_PASSWORD_FILE"
#
fi

# Log updated environment variables
# echo "===== Environment variables ======"
# env | sort
# echo "=================================="

echo "Starting Joplin daemon..."

# Change directory to where the Joplin server is located
cd /home/joplin/packages/server

# Start Joplin server
exec yarn start-prod

In order for the custom entrypoint file to be called at start up, the docker-compose file needs to be updated as follows:

  app:
    image:  joplin/server:latest
    entrypoint:  /bin/bash 
    command:  /secret_entrypoint.sh
    volumes:
    - 'secret_entrypoint.sh:/secret_entrypoint.sh'

Now, having done all of that, I'm still unable to get Joplin to connect to Postgres.

Here's my notes:

  • In the Joplin Server logs I see: Could not connect. Will try again. password authentication failed for user "joplin"
  • At the top of the logs, I can see the environment variables when I uncommented the env | sort line and it shows that $POSTGRES_PASSWORD_FILE and $POSTGRES_PASSWORD are getting set as expected through the custom entrypoint script.
  • I had actually edited /home/joplin/packages/server/dist/env.js to print the environment variables and can see that $POSTGRES_PASSWORD is actually getting set properly once Joplin Server is started using exec yarn start-prod in my custom entrypoint script.
  • If I log into the shell for Joplin Server and run env | sort, I do not see the $POSTGRES_PASSWORD environment variable. However, this may be due to starting the shell outside of the process that originally set the environment variable.
  • I learned that if I were to edit /etc/environment to include the $POSTGRES_PASSWORD=my_super_secret_password from the custom entrypoint file, it would take effect across the whole container. However I'm not able to do this because editing /etc/environment takes special privileges that whatever the default user is doesn't have. Also, it's unclear to me what the credentials might need to be in order to edit the /etc/environment file.
  • If I forego all of this and just use "admin" for the $POSTGRES_PASSWORD in the docker compose file, Joplin Server is able to connect to the Postgres database just fine.

External links:

  • Docker docs on creating and using secrets: https://docs.docker.com/engine/swarm/secrets/
  • Postgres docs on using the _FILE convention to pass in Docker secrets: https://github.com/docker-library/docs/blob/master/postgres/README.md#docker-secrets
  • Postgres entrypoint where the Docker image handles the _FILE convention: https://github.com/docker-library/postgres/blob/1424abf76f421d6f7bf933d9e42bbbed866fae3a/docker-entrypoint.sh#L9

Update!

The custom entrypoint file I mentioned above actually works! The problem I was having was using the wrong hostname in the $POSTGRES_HOST environment variable.

When using Docker Swarm, the name of the service is created as [stack_name]_[service_name]. Since the name of my particular stack is dev_home_network and the name of my Postgres service is joplin_db, the name of my service that Docker identifies as the hostname for my Postgres server is dev_home_network_joplin_db.

For the sake of completeness in case anyone is trying to get this working, this is my full docker-compose file as it pertains to hosting Joplin Server:

  joplin_db:
    image: postgres:latest
    volumes:
    - ./container_state/joplin_db/data:/var/lib/postgresql/data
    ports:
    - "5432:5432"
    restart: unless-stopped
    secrets:
      - joplin_db_postgress_password
    environment:
    - POSTGRES_PASSWORD_FILE=/run/secrets/joplin_db_postgress_password
    - POSTGRES_USER=joplin
    - POSTGRES_DB=joplin

  joplin:
    image: joplin/server:latest
    entrypoint: /bin/bash 
    command:  /secret_entrypoint.sh
    volumes:
    - 'secret_entrypoint.sh:/secret_entrypoint.sh'
    depends_on:
    - joplin_db
    ports:
    - "22300:22300"
    restart: unless-stopped
    secrets:
      - joplin_db_postgress_password
    environment:
    - APP_PORT=22300
    - DB_CLIENT=pg
    - POSTGRES_PASSWORD_FILE=/run/secrets/joplin_db_postgress_password
    - POSTGRES_DATABASE=joplin
    - POSTGRES_USER=joplin
    - POSTGRES_PORT=5432
    - POSTGRES_HOST=dev_home_network_joplin_db
    - MAX_TIME_DRIFT=0

secrets:
  joplin_db_postgress_password:
    external: true

And this is the contents of secret_entrypoint.sh:

#!/usr/bin/bash

# POSTGRES_PASSWORD_FILE='/run/secrets/joplin_db_postgress_password'

# Update the POSTGRES_PASSWORD environment varible to be the contents of the secrets file, if it exists
if [[ -f $POSTGRES_PASSWORD_FILE ]]; then
    POSTGRES_PASSWORD=`cat $POSTGRES_PASSWORD_FILE`
    export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"

else
    echo "Secret file not found.  Was expecting to find one at $POSTGRES_PASSWORD_FILE"
fi

# Log updated environment variables
echo "===== Environment variables ======"
env | sort
echo "=================================="

echo "Starting Joplin daemon..."

# Change directory to where the Joplin server is located
cd /home/joplin/packages/server

# Start Joplin server using Yarn
exec yarn start-prod

It would be great if the Joplin Server image was able to handle the _FILE convention similar in the way that the Postgres image handles it. It would mean that I wouldn't need the secret_entrypoint.sh file.