Skip to content

Install with Docker

A Cofre Ink server of your own gives you accounts with email and password, the same data on every device that signs in, and shared spaces where you can invite other people. It is one container that serves the interface and the data on one port. This page goes from an empty machine to the first sign in, then covers updating, backups and what can go wrong.

If only you will use Cofre Ink, on one device, you need none of this: app.cofre.ink keeps the database in your browser, with no account. Where your data lives compares the two. Keep in mind that with a server, every device has to reach it to work.

  1. Docker, with the docker compose command. The repository names no minimum version, and no minimum memory or disk.
  2. Git, to download the code.
  3. Node, only to make the secret in step 3, since the container brings its own. Any random string of at least 32 characters will do instead.
  4. An internet connection for the first build. Building the image downloads pnpm and the packages Cofre Ink uses.
  5. Port 4321 free on the machine, or another port you choose with COFRE_PORT.

Every command on this page is the repository’s own or was run while writing this guide, in PowerShell on Windows 11 with Docker Desktop. They read the same in a Linux or macOS terminal. The Windows Command Prompt (cmd.exe) does not understand ${PWD} in the backup commands: use PowerShell there.

The repository carries this file at its root. You do not edit it: what you set goes in .env, and Compose fills the ${...} parts from there.

compose.yaml
# Cofre on your own machine.
#
# 1. cp .env.example .env
# 2. fill COFRE_SECRET
# 3. docker compose up -d
#
# The data lives in the named volume below, so it survives an update of the image.
# For PostgreSQL instead of SQLite, read the note above the database service further
# down: there are four things to uncomment, and leaving one out stops the whole file.
# The project name comes from here and not from the folder, so the container and the
# volume are called the same thing on every machine.
name: cofre
services:
cofre:
build: .
restart: unless-stopped
ports:
- "${COFRE_PORT:-4321}:4321"
environment:
COFRE_PORT: 4321
COFRE_DATABASE: ${COFRE_DATABASE:-/data/cofre.db}
COFRE_SECRET: ${COFRE_SECRET:?set COFRE_SECRET in .env}
COFRE_WEB_ORIGIN: ${COFRE_WEB_ORIGIN:-http://localhost:4321}
COFRE_PUBLIC_URL: ${COFRE_PUBLIC_URL:-http://localhost:4321}
# The rest of what .env.example offers. Without these lines a person fills the
# file, brings the container up and finds the widget they asked for is not there,
# which is worse than not offering it: they think something is guarding the door.
COFRE_PROOF_BITS: ${COFRE_PROOF_BITS:-18}
COFRE_CLIENT_IP_HEADER: ${COFRE_CLIENT_IP_HEADER:-}
COFRE_TURNSTILE_SITE_KEY: ${COFRE_TURNSTILE_SITE_KEY:-}
COFRE_TURNSTILE_SECRET: ${COFRE_TURNSTILE_SECRET:-}
NODE_ENV: production
volumes:
- cofreData:/data
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4321/health').then(r => process.exit(r.ok ? 0 : 1))"]
interval: 30s
timeout: 5s
retries: 3
# Uncomment this with the database service below, and not on its own: Compose
# refuses the whole project when a service depends on one that is not there.
#
# Waiting for the container to exist is not enough, which is why this waits for it
# to be healthy. PostgreSQL starts, writes its own files, restarts itself once and
# only then opens the socket, so a server that connects the moment the container
# appears is refused and dies on the first boot.
# depends_on:
# database:
# condition: service_healthy
# PostgreSQL instead of SQLite. Four things to uncomment, not one: this service, the
# depends_on above, the volume it writes to at the bottom of this file, and
# POSTGRES_PASSWORD in .env. Then point COFRE_DATABASE at it:
# COFRE_DATABASE=postgres://cofre:${POSTGRES_PASSWORD}@database:5432/cofre
# The host is `database`, the name of the service, because that is what the address
# resolves to from inside this network. It is not localhost, which in there is the
# container asking itself.
# database:
# image: postgres:17-alpine
# restart: unless-stopped
# environment:
# POSTGRES_USER: cofre
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
# POSTGRES_DB: cofre
# volumes:
# - cofrePostgres:/var/lib/postgresql/data
# # What the depends_on above waits for. Asking the database itself, rather than
# # watching a port, because the port is open for a moment during the start before
# # it is ready to answer.
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U cofre -d cofre"]
# interval: 5s
# timeout: 5s
# retries: 12
volumes:
cofreData:
# The other half of the database service above. Compose refuses the whole project when
# a service mounts a volume nothing declares, so uncommenting one without the other
# brings nothing up at all.
# cofrePostgres:
  1. name: cofre fixes the project name, whatever the folder is called. The container and the volume carry it, which is why the volume is called cofre_cofreData in the backup commands below.
  2. build: . builds the image on your machine from the Dockerfile next to this file. It builds the interface first, then an image on node:24-alpine that runs the server as the user node, not as root.
  3. restart: unless-stopped starts the container again after a crash or a restart of Docker, unless you stopped it yourself.
  4. ports publishes the server on the port of your machine named by COFRE_PORT, 4321 when nothing sets it. Inside the container the server always listens on 4321.
  5. environment hands the settings to the server. ${NAME:-value} takes the line NAME= of .env and falls back to the value after :- only when .env leaves it out or empty, so a line in .env always wins over the default written here. ${COFRE_SECRET:?...} has no default: Compose will not start the container without a secret. NODE_ENV is fixed at production.
  6. volumes mounts the named volume cofreData at /data inside the container. What is written under /data survives when the container is replaced. What is written anywhere else does not.
  7. healthcheck asks the server at /health every 30 seconds. docker compose ps shows the answer as (healthy).
  8. The commented depends_on, database service and cofrePostgres volume are the PostgreSQL path. They are uncommented together, with a line in .env, as the comments in the file say. See PostgreSQL.
Terminal window
git clone https://github.com/AndreiLud/app-cofre-ink
cd app-cofre-ink

Every command from here on runs inside this folder.

Terminal window
cp .env.example .env

.env.example lists every setting with a comment on each. cp works in PowerShell too. It replaces an existing .env without asking, so run it only once: on a server already in use, it would throw away your secret and the addresses of step 4.

Terminal window
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"

It prints 64 characters. Open .env in a text editor and paste them right after COFRE_SECRET=, on the same line. Without Node, any random string of at least 32 characters will do. A shorter one stops the server when it starts.

The secret signs the sessions of whoever is signed in. It is not your password. Keep it out of sight, and keep it: changing it signs everybody out.

4. Leave the rest commented, until the address changes

Section titled “4. Leave the rest commented, until the address changes”

COFRE_SECRET is the only line that has to be filled for the container. In the words of .env.example, “everything left commented falls through to a default that already suits it.” A commented line still matters, because whatever .env sets wins over compose.yaml, so leave these alone unless you mean them:

  1. # COFRE_DATABASE= stays commented. Compose then uses the default of compose.yaml, /data/cofre.db, in the folder where the volume is mounted, so the database survives updates. A path written there that is not under /data puts the database inside the container, where the next update deletes it. Fill it in only for PostgreSQL.
  2. # COFRE_WEB_ORIGIN= and # COFRE_PUBLIC_URL= stay commented while Cofre Ink is used only on the machine running Docker. Both are then http://localhost:4321, because the container serves its own interface and the two are the same address. COFRE_WEB_ORIGIN is where the interface is opened, and every invitation link is built with it. COFRE_PUBLIC_URL is where the server answers, seen from outside.

The moment the server is reached as anything other than localhost (from another device, by a domain, over https://), remove the # from both address lines and put the real address in both:

.env
COFRE_WEB_ORIGIN=https://cofre.yourhouse.com
COFRE_PUBLIC_URL=https://cofre.yourhouse.com

Both addresses are the one people type in the browser to open Cofre Ink, with http:// or https:// at the start and no slash at the end. http://localhost:4321 works only on the machine running Docker. If other devices will use the server, a phone at home for example, put in both lines the address those devices use, and open Cofre Ink by that same address on every device, this machine included: the server accepts a sign in only from a page opened at one of these two addresses. For an address on the internet, read HTTPS and a reverse proxy first.

Terminal window
docker compose up -d

The first time, Compose builds the image before starting it. That needs the internet and takes a while: about a minute on the machine used for this guide, which already had the Node base image. Later starts take seconds.

Terminal window
docker compose ps

The status reads (health: starting) for about 30 seconds, then (healthy).

Terminal window
docker compose logs --tail 50

Among the lines, look for these two:

Cofre Ink is listening on http://localhost:4321
storing data in /data/cofre.db

The first always says localhost, whatever address you set. The second has to name a file under /data. If it says storing data in ./data/cofre.db, .env still holds the database line of an older copy, as the caution in step 4 describes: put a # in front of it, run docker compose up -d again, and check once more before anybody creates an account.

Open http://localhost:4321 in a browser on the same machine, or the address you set in step 4.

The interface opens in Portuguese on a device whose clock is set to a time zone of Brazil, and in English anywhere else. The PT EN button at the top, named Language, switches it, and that browser keeps the choice. The labels below are the English ones.

  1. The first screen asks How do you want to use Cofre Ink?. The container serves the same interface as app.cofre.ink, and it does not know by itself that it came from your server, so you tell it.
  2. Under Sync between my devices, press See both ways.
  3. A server of mine is already chosen, and Address of the server comes filled with http://localhost:4321. On the machine running Docker, keep it. Anywhere else, type the address from step 4 with http:// at the start: an address typed without http:// or https:// is read as https://, which this server does not answer.
  4. Press Connect.
  5. The screen says Create your access, with a box that says This server has nobody on it yet. Fill What you are called, Email and Password, which asks for Ten characters at least.
  6. Press Create the account and sign in. It takes a few seconds, because the browser first solves a small puzzle that makes guessing passwords in bulk slow.

The Overview of your personal space, Personal, opens empty. If Categories, in Settings, has none, press Use the starting set there. First steps goes on from here.

What to know about the accounts:

  1. There is no default password and none written anywhere. The one you chose is the only one.
  2. There is no password reset and no way to change a password in the interface, and the server sends no email. Keep the password somewhere safe.
  3. Signing up stays open to anybody who reaches the address. A new account starts empty and sees nothing of yours. The first account has no special power over the server: roles exist inside each space.
  4. From now on the sign in screen of this server opens on Sign in. Somebody new presses I do not have an account yet.
  5. The choice of server is kept in each browser. Every other device goes through steps 1 to 4 above with the same address.

Invitations need a shared space, because the personal space takes no members.

  1. Open the space menu (the button with the name of the space, at the top), choose Manage spaces, then New space. Give it a name and press Save.
  2. With that space open, choose Members in the same menu and press Invite.
  3. Choose a Role, press Make a link, then Copy the link.

The link starts with the address in COFRE_WEB_ORIGIN and is Good for seven days, and for one person. The other person opens it, connects to the same server as in The first visit, creates an account and joins. Members and invitations explains the roles and the rest.

The Invite dialog over the Members screen of the shared space Casa on a server, with the link made, the button Copy the link and the note Good for seven days, and for one person.(opens the picture at full size)

The server speaks plain HTTP on port 4321 and has no certificate of its own. On one machine, or on a home network you trust, that is enough. To reach it from the internet you need a reverse proxy in front of it, with a real certificate. The repository ships no proxy configuration, so use the one you know. Then, in .env:

  1. Remove the # in front of COFRE_PUBLIC_URL and COFRE_WEB_ORIGIN and set both to the public address, starting with https://.
  2. Remove the # in front of COFRE_CLIENT_IP_HEADER and set it to the header your proxy fills with the address of the caller, usually x-forwarded-for: the limit on sign in attempts reads who is calling from it. Leave it empty when nothing sits in front.
  3. Run docker compose up -d again, so the container starts with the new values.

With https:// in COFRE_PUBLIC_URL, the browser keeps the session only over https, so the proxy has to answer with a valid certificate or nobody stays signed in.

The interface at app.cofre.ink with your server. You can also connect the interface at app.cofre.ink to your server, in place of the one your server serves. Then COFRE_WEB_ORIGIN must be https://app.cofre.ink and COFRE_PUBLIC_URL the address of your server, which is the one case where the two differ, and your server must answer over https with a real certificate, or nobody stays signed in: to the browser they are two different sites. The project’s automated tests do not cover this combination. Opening the address of your own server avoids all of it.

This page sets up SQLite, one file in the volume, which is the default. For PostgreSQL, compose.yaml carries a database service on postgres:17-alpine, and four things are uncommented together. Leaving one out stops the whole file, because Compose refuses a service that mounts a volume, or depends on a service, that nothing declares.

  1. The database service in compose.yaml, with its healthcheck.
  2. The depends_on of the cofre service, further up the same file.
  3. The cofrePostgres volume, at the bottom of the file. It sits apart from the service, so it is the easy one to miss.
  4. POSTGRES_PASSWORD in .env, the password the database is created with.

The second one keeps the server from reaching for the database too early: PostgreSQL restarts itself once while it starts, so Compose waits for its healthcheck to answer before starting the server.

Then point the server at it, in the same .env:

.env
POSTGRES_PASSWORD=something long
COFRE_DATABASE=postgres://cofre:${POSTGRES_PASSWORD}@database:5432/cofre

Compose expands ${POSTGRES_PASSWORD}, so the password is written once. The host is database, the name of the service, and not localhost, which inside the container is the container itself. Run docker compose up -d, and the log says storing data in postgres://cofre:***@database:5432/cofre.

This path was run while writing this guide: docker compose up -d waited for the database to be healthy, then started the server, which connected at the first try.

It is still the less proven path: the project’s automated tests run PostgreSQL only on PGlite, inside the test process. The volume copy below copies cofreData, which holds nothing here, so back PostgreSQL up with pg_dump, or with the copy from the interface, which works the same with either database.

  1. Take a backup first, as described below. The repository describes no way back to an older version, so that copy is your way back.
  2. Read the changelog: it says what changed in each version, and what to do about it if you run a server.
  3. In the folder of the clone, run:
Terminal window
git pull
docker compose up -d --build

git pull brings the new code and --build rebuilds the image from it. When the image changed, Compose replaces the container. When the database needs a change of shape, the server applies it by itself as it starts. Your records stay in the volume as long as the database is under /data, which it is while the COFRE_DATABASE line stays commented: afterwards, docker compose logs --tail 50 should again say storing data in /data/cofre.db. git pull leaves .env as it was, so if yours came from an older copy of the repository, read the caution in step 4 before updating.

There are two kinds of copy, and they hold different things:

  1. A copy of the volume, made from the command line, holds the whole server: every account, space, member and invitation.
  2. A copy from the interface holds the spaces of the person signed in, in a file any Cofre Ink can bring back. It carries no accounts, and not who is in each space.

Run these in the folder of the clone. The server stops for a few seconds, and the file cofre.tar.gz lands in that folder.

Terminal window
docker compose stop
docker run --rm -v cofre_cofreData:/data -v ${PWD}:/out alpine tar czf /out/cofre.tar.gz /data
docker compose start

The first time, Docker downloads the alpine image. The line tar: removing leading '/' from member names is expected. The file holds data/cofre.db, sometimes with cofre.db-wal and cofre.db-shm beside it, which is harmless. It has no password and holds every account and record, so keep it where only you can reach it.

This replaces everything in the volume with what is in the file: whatever was written after the copy is lost. Put cofre.tar.gz in the folder of the clone and run:

Terminal window
docker compose stop
docker run --rm -v cofre_cofreData:/data -v ${PWD}:/out alpine sh -c "find /data -mindepth 1 -delete && tar xzf /out/cofre.tar.gz -C /"
docker compose start

The second command empties the volume before unpacking, so no file left from the current database mixes with the restored one. On a new machine, follow the install up to step 6 first, so that Compose creates the volume, then run the three commands.

In Settings, Data, Save a copy writes a file with everything in the open space when you press Save, and Bring it back, with Choose a file, joins such a file with what is already there. With more than one space, Backup of every space, under Take the data to another program, saves in one file every space where you are Owner or Administrator. These copies work with SQLite and with PostgreSQL alike. Backup and restore has the details.

The server reads these and nothing else. Compose passes them from .env, and a line in .env replaces the value shown after “Under Compose”.

COFRE_SECRETrequiredUnder Compose: none, and Compose will not start without it

Signs the session cookies and the puzzle the browser solves before a sign in. At least 32 characters. Changing it signs everybody out.

COFRE_DATABASEoptionalUnder Compose: /data/cofre.db

Where the data lives. A file path means SQLite, and a value starting with postgres means PostgreSQL. .env.example leaves it commented. Keep a SQLite file under /data, so it lands in the volume.

COFRE_WEB_ORIGINoptionalUnder Compose: http://localhost:4321

The address the interface is opened from. Invitation links start with it, and a sign in is accepted from it. .env.example leaves it commented, like the next one: set both as soon as the server is reached as anything other than localhost.

COFRE_PUBLIC_URLoptionalUnder Compose: http://localhost:4321

The address the server answers on, as seen from outside: behind a proxy, the public one. A sign in is accepted from it too. With https://, the session is kept only over https.

COFRE_PORToptionalUnder Compose: 4321

The port on your machine. Inside the container the server always listens on 4321.

COFRE_CLIENT_IP_HEADERoptionalUnder Compose: empty

The header a reverse proxy fills with the address of the caller, usually x-forwarded-for. Only with a proxy in front.

COFRE_PROOF_BITSoptionalUnder Compose: 18

How hard the puzzle before a sign in is, from 0 to 26. Each step doubles the work: 20 is four times 18, and 16 a quarter. 0 turns it off.

COFRE_TURNSTILE_SITE_KEYoptionalUnder Compose: empty

The public key of a Cloudflare Turnstile widget on the sign in screen. Only together with the secret below.

COFRE_TURNSTILE_SECREToptionalUnder Compose: empty

The secret of that widget, which stays on the server. With both set, every sign in and sign up is checked with Cloudflare.

NODE_ENVfixedUnder Compose: production, written in compose.yaml

Nothing to set.

COFRE_STATIC_DIRfixedUnder Compose: /app/apps/server/public, set by the image

Where the built interface sits, so the same container serves it.

A few of them behave differently under Compose:

  1. NODE_ENV and COFRE_STATIC_DIR do nothing when written in .env. compose.yaml fixes the first, and the second comes from the image and is not passed on.
  2. COFRE_PORT only moves the port on your machine. If you change it, put the new port in the two addresses as well.
  3. Turnstile stays off unless both of its lines are filled, and the server refuses to start with only one. When it is on, Cloudflare learns the address of everybody who opens your sign in page, and a sign in is refused whenever Cloudflare does not answer. The project has tested it only against a stand in, never against Cloudflare itself.
  4. POSTGRES_PASSWORD is read by the PostgreSQL service and by the COFRE_DATABASE line that uses it, not by the server itself.

To see what the server said, run docker compose logs --tail 50 in the folder of the clone.

The line COFRE_SECRET= in .env is empty. Make a secret as in step 3, paste it there and run docker compose up -d again.

The container stops as it starts, and the log says this, then where the values go (with Docker, in .env), then which setting and why. The most common is COFRE_SECRET shorter than 32 characters. Correct the line in .env and run docker compose up -d.

COFRE_TURNSTILE_SECRET is set without COFRE_TURNSTILE_SITE_KEY

Section titled “COFRE_TURNSTILE_SECRET is set without COFRE_TURNSTILE_SITE_KEY”

Or the other way round. The two Turnstile lines go together: fill both, or empty both.

The build downloads pnpm and packages from the internet, and it stops, with messages such as connect ETIMEDOUT, when it cannot reach them. Check the connection and run docker compose up -d again.

The container starts and dies because the image is incomplete. Rebuild it from scratch, then start it:

Terminal window
docker compose build --no-cache
docker compose up -d

This happens with a .env made from an older copy of the repository. If the log said storing data in ./data/cofre.db, .env still held the line COFRE_DATABASE=./data/cofre.db, and the database lived inside the container that the update replaced. What was written that way cannot be brought back from the server. Put a # in front of the line, as the caution in step 4 says, and run docker compose up -d. From then on the data lives in the volume. If you saved copies from the interface, create your account again and bring them back with Bring it back.

If you notice the line before any update, save a copy of each space from the interface first, as Backup and restore explains, and run neither docker compose down nor docker compose up -d --build before that: recreating the container is what removes the file. Then comment out the line, run docker compose up -d, create the account again and bring the copies back.

.env came from an older copy of the repository and still holds COFRE_WEB_ORIGIN=http://localhost:5174. Put a # in front of the line on a single machine, or set both addresses to the real one as in step 4, then run docker compose up -d and make a new link.

I could not reach an address that starts with https://

Section titled “I could not reach an address that starts with https://”

The line under the title of the sign in screen, On the server, shows the address this browser is using. An address typed without http:// was read as https://, which this server does not answer, and sending the form says I could not reach with that address. The same message appears when the server is off, so check docker compose ps too.

This browser remembers the address it was given. To be asked again, clear the data the browser keeps for the page, in its site settings, then open the page and type the address with http://. At the address of your server that data holds this choice and preferences such as the language and the theme, unless you also used In this browser only there, whose database would go with it.

The sign in screen shows this for a wrong password and for every other refusal: too many attempts, a page opened at an address the server does not trust, or Turnstile. When creating an account, the same goes for I could not create the account. That email may already be taken.

  1. Wait a minute and try again. The server allows five sign in attempts a minute and five new accounts an hour. With nothing in front and no COFRE_CLIENT_IP_HEADER, it cannot tell callers apart and counts everybody together, which is also why the log shows a warning that starts with Rate limiting could not determine a client IP, once after each start.
  2. Check that the address in the browser is exactly the one in COFRE_WEB_ORIGIN or COFRE_PUBLIC_URL.

Signing in works, and the next screen asks again.

  1. The interface and the server are on different sites, as with app.cofre.ink and a server of yours. Check that COFRE_WEB_ORIGIN is the address the interface really comes from, that COFRE_PUBLIC_URL starts with https://, and that the certificate is real, not self signed. Serving the interface from your own container avoids this.
  2. COFRE_SECRET changed, which signs everybody out. Sign in again.

The repository’s own deploy guide and troubleshooting guide, written alongside the code, go into more cases than this page. Whoever you ask for help, never send your .env: it holds the secret.

Related pages: Where your data lives, Members and invitations, Changing where the data lives, Backup and restore.