Can I self-host Asana?
YES · ONE EVENING— setup effort 2 of 4YES — it's called OpenProject. It takes one prompt, a 4096 MB VPS, and about 150 minutes. That is $67.45 a month you stop paying Asana — $809.40 a year on the Starter plan, 5 seats assumed.
Why people pay for Asana
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Asana sells the part of project management that is not a database: a task list light enough that people who did not choose the tool still update it. You are paying per seat for an interface your least enthusiastic colleague will actually open, for the integrations that push work into it from Slack and email, and for somebody else keeping all of that running while your team grows.
| Plan | List price | What it buys |
|---|---|---|
| Personal | free | Free forever. Asana's Personal plan page states up to 2 seats per project and team, and its Starter page describes Starter as the plan for a team of more than 2. |
| Starterthe plan this page prices against | $13.49/mo per seat | Per user per month billed monthly. $10.99 per user per month on the annual plan, which is the figure the page leads with. No seat limit. |
| Advanced | $30.49/mo per seat | Per user per month billed monthly. $24.99 per user per month on the annual plan. No seat limit. |
| Enterprise | quote only | Quote only. The page says contact sales for pricing, with unlimited users. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high
Replaced by OpenProject
One project, named before the prompt, so you know what you are about to install.
Classic project management with work packages, Gantt charts and time tracking, on a server you own and with no per-seat bill.
The honest swap for a team that had outgrown Asana's list rather than one that loved it. You get work packages with types and statuses, Gantt charts, a work breakdown structure, time and cost tracking, and no seat counter, all from one container plus a database. What you give up is the lightness: OpenProject asks more of every task you create, and its notifications go nowhere until you configure mail, which Asana does for you.
The swap
You'd run
OpenProject
ONE EVENING · ~150 min to running · 4096 MB RAM
Asana Starter · 5 seats assumed · vendor list price · checked 2026-08-06 · source
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~150 min1–3 hours, through the first backup
The prompt
Two paths to the same OpenProject: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
316 lines · 14,943 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install OpenProject 17.7.1 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say this when you ask: `<DOMAIN>` becomes `OPENPROJECT_HOST__NAME`, which every link and form
action is built from, and upstream warns that a container reached on a name it was not told
about is open to Host header injection. Its A record must already point at this server.
Upstream publishes a floor of 4096 MB of RAM and 20 GB of disk, and this Rails application holds
itself in memory twice, for the web process and for the worker. Both images publish amd64 and
arm64. Measure all four first:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop: the
first boot runs every migration and then the seeder, and an OOM kill in the middle leaves a
half-seeded database. If `dig +short` prints nothing, print that and stop, because Caddy cannot
get a certificate for a name that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/openproject /srv/openproject/backups /srv/openproject/assets
sudo install -d -m 700 /srv/openproject/postgres
ls -la /srv/openproject
```
Assert: `ls -la` shows `backups` and `assets` owned by the login user and `postgres` at mode
`drwx------` owned by root. The PostgreSQL image chowns its own data directory on first start,
so leave that one alone. `assets` holds every file attached to a work package; the container
chowns it to its internal `app` user, so its owner changing in step 7 is expected.
## 3. Secrets
Three secrets, all generated here on the server. `SECRET_KEY_BASE` signs sessions and derives
the key for encrypted database columns, `OPENPROJECT_SEED_ADMIN_USER_PASSWORD` replaces the
password the seeder would otherwise put on the `admin` account, and `DB_PASSWORD` is the
PostgreSQL password. Hex rather than base64: one travels inside a connection string, and
OpenProject parses environment values as YAML, where base64 punctuation is a hazard. Do not
print any of them and keep them out of your summary and every log line.
```bash
umask 077
cat > /srv/openproject/.env <<EOF
OPENPROJECT_HOST__NAME=<DOMAIN>
OPENPROJECT_HTTPS=true
SECRET_KEY_BASE=$(openssl rand -hex 64)
OPENPROJECT_SEED_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/openproject/.env
umask 022
ls -l /srv/openproject/.env
```
Assert: the file exists with mode `-rw-------` and the login user's name twice. Replace
`<DOMAIN>` on the first line with the real hostname before writing. Docker Compose reads this
same file for the `${DB_PASSWORD}` substitution in compose.yml. Upstream states OpenProject
refuses to start on a weak `SECRET_KEY_BASE`, and that it must stay the same across restarts or
sessions and encrypted columns become unreadable: that is why step 8 archives it with the dump.
## 4. compose.yml
```bash
cat > /srv/openproject/compose.yml <<'EOF'
# OpenProject · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://www.openproject.org/docs/installation-and-operations/installation/docker/
# docker compose ..... https://www.openproject.org/docs/installation-and-operations/installation/docker-compose/
# configuration ...... https://www.openproject.org/docs/installation-and-operations/configuration/
# health endpoints ... https://www.openproject.org/docs/installation-and-operations/operation/monitoring/
#
# Two services. The all-in-one image runs Puma, the worker, memcached, the
# collaborative-editing server and an Apache under one supervisord, so
# upstream's nine-service compose file for the slim image collapses to one
# container here. It starts its own PostgreSQL only when DATABASE_URL points
# at 127.0.0.1; ours points at the postgres service below. Upstream supports
# PostgreSQL 16 and above. Digests read on 2026-08-06; both images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: openproject-db
restart: unless-stopped
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/openproject/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openproject -d openproject"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
openproject:
image: openproject/openproject:17.7.1@sha256:bbaaedbe3837097dd189f739565064accb731d23cd87294a21bd07e0be010f6a
container_name: openproject
restart: unless-stopped
# Hostname, HTTPS flag, SECRET_KEY_BASE and the seeded admin password all
# arrive from /srv/openproject/.env, mode 600 on the host.
env_file: /srv/openproject/.env
environment:
# No query string: the all-in-one start-up script hands DATABASE_URL to
# psql through a shell, where `&` would background the command.
DATABASE_URL: postgres://openproject:${DB_PASSWORD}@postgres/openproject
RAILS_MIN_THREADS: "4"
RAILS_MAX_THREADS: "16"
# Inbound mail off: no IMAP poller, no cron process for it.
IMAP_ENABLED: "false"
volumes:
- /srv/openproject/assets:/var/openproject/assets
healthcheck:
# Migrations and the seeder run before Apache exists: long start period.
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health_checks/default || exit 1"]
interval: 15s
retries: 20
start_period: 600s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8116.
- "127.0.0.1:8116:80"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/openproject && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Do not set `SERVER_NAME` on the container. Left unset, the
Apache inside the image renders one catch-all site that answers on any hostname; set, it renders
a second site that returns a warning page to every request whose Host does not match, including
the health check on 127.0.0.1.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-openproject
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OpenProject · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.openproject.org/docs/installation-and-operations/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also OPENPROJECT_HOST__NAME in .env, which OpenProject builds every link and
# form action from.
<DOMAIN> {
# The Angular bundle and the work package tables are worth compressing.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8116 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. reverse_proxy carries
# the /hocuspocus WebSocket upgrade with no extra directive.
reverse_proxy 127.0.0.1:8116
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-openproject, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it on its own, and it sets
`X-Forwarded-Proto: https` on every proxied request, which lets OpenProject keep
`OPENPROJECT_HTTPS=true` while speaking plain http on 8116.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8116 stays closed because compose binds it to 127.0.0.1, and 5432 because
compose never publishes it at all. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8116 or 5432.
## 7. Start and verify
The container runs every migration and then the seeder before Apache starts, so the first boot
takes minutes, not seconds. The loop below allows ten of them.
```bash
cd /srv/openproject
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health_checks/default); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health_checks/default
curl -sS https://<DOMAIN>/login | grep -o '<h1>Sign in</h1>'
docker compose exec -T openproject bundle exec rails runner 'puts "shipped-default-still-works=" + User.find_by(login: "admin").check_password?("admin").to_s'
```
Assert all four, and print what you received for each. The loop ends printing `200`. The health
response contains `PASSED`. The third command prints `<h1>Sign in</h1>`, the heading on the
first screen a human sees. The fourth prints `shipped-default-still-works=false`, the security
assert here: upstream seeds an account with login `admin` and password `admin`, and step 3
replaced that password before the seeder ran. If it prints `true`, stop and do not report
success, because a known password is sitting on a public hostname.
If any of the four misses, stop, run `docker compose logs --tail 60 openproject` and
`docker compose logs --tail 20 postgres`, and name the likely earlier step: a database that
never reports healthy points at step 2; a `502` while the log still prints migration output
wants more time, not a fix; a page of warning text about a domain means `SERVER_NAME` was set in
step 4. A running container is not success.
STOP: tell the user to read the seeded password with
`sudo grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD /srv/openproject/.env`, put it in their password
manager, then sign in at https://<DOMAIN>/login as `admin`, and wait. Do not continue until they
confirm. OpenProject forces a password change on that first sign-in, so the seeded value exists
only so that no account here answers to a password printed in upstream's documentation. The
account carries `admin@example.net`, which they change under their own profile.
## 8. First backup and restore
Two artifacts. The database holds every project, work package, comment and user. The file
archive holds the attachments plus the three files that rebuild the service around them.
```bash
cd /srv/openproject
docker compose exec -T postgres pg_dump -U openproject -d openproject -x -O | gzip > /srv/openproject/backups/openproject-db-$(date +%F).sql.gz
sudo tar -czf /srv/openproject/backups/openproject-files-$(date +%F).tar.gz -C /srv/openproject compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh /srv/openproject/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. A backup on the same disk is not a backup,
so run this from the user's machine:
```bash
mkdir -p ~/backups/openproject
scp vps:/srv/openproject/backups/* ~/backups/openproject/
```
To restore: `docker compose down`, `sudo rm -rf /srv/openproject/postgres`, recreate it as in
step 2, untar the file archive into /srv/openproject so .env is back before anything starts,
`docker compose up -d postgres`, wait about 30 seconds for it to report healthy, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U openproject -d openproject`, then `docker compose up -d`.
The order matters twice: PostgreSQL takes its password from .env the moment it initialises
an empty directory, and the `SECRET_KEY_BASE` in that file decrypts the encrypted columns in the
dump, so a database restored without its .env is one nobody can read.
## 9. Updating later
New versions are listed at https://github.com/opf/openproject/releases. Take both backup
artifacts first, then edit the image line in /srv/openproject/compose.yml to the new tag and its
digest:
```bash
cd /srv/openproject
docker compose pull
docker compose up -d
docker compose logs --tail 40 openproject
```
OpenProject migrates its own database on the way up, and a minor-version jump can take as long
as the first boot. Watch that log until it settles, then re-run the health check from step 7
before calling the update done. Read the release notes before crossing a major version: those
carry migration steps this prompt does not.
## 10. What will probably go wrong
You will think the install has hung. I did. After `docker compose up -d` the container runs
every migration and then the seeder, and until that finishes there is no Apache inside it, so
https://<DOMAIN> returns a Caddy `502` while `docker compose ps` shows a healthy database beside
an app doing nothing. On a 4 GB box it was over four minutes before the first `200`, and
I had already opened the compose file twice looking for a mistake that was not there. Let the
loop in step 7 run all sixty attempts before touching anything; `docker compose logs -f
openproject` prints each migration as it lands.
## 11. Out of scope
- Do not configure SMTP. OpenProject runs without it, so every notification it would have
emailed stays inside the web interface.
- Do not set `IMAP_ENABLED` or configure inbound mail. Creating work packages by email needs a
mailbox the user owns and polls, a separate decision from this install.
- Do not switch to the `-slim` image or split this into upstream's nine-service compose file.
The all-in-one container is the shape this prompt installs.
- Do not install the BIM edition. It is a different image, amd64 only, and it is for
construction models rather than project management.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing OpenProject 17.7.1 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.
Read this before step 1. `<DOMAIN>` becomes `OPENPROJECT_HOST__NAME`, the name OpenProject
builds every link and form action from, and upstream warns that a container reached on a name it
was not told about is open to Host header injection. Set aside the better part of an evening:
this is a large Rails application and its first boot runs every database migration and then a
seeder before it answers anything.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `4096` MB available, at least `20` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: those two floors are upstream's own numbers for a single-server install, and this
is the wrong place to argue with them. A 2 GB box will get through the migrations and then meet
the OOM killer somewhere in the seeder, which looks like a corrupt database rather than a memory
problem. An empty last line means the A record does not exist yet: add it, wait a minute, run
`dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve and failed attempts count against a rate limit you cannot see.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/openproject /srv/openproject/backups /srv/openproject/assets
sudo install -d -m 700 /srv/openproject/postgres
ls -la /srv/openproject
```
You should see: `backups` and `assets` owned by you, and `postgres` at mode `drwx------` owned
by root.
If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `assets` is where every file anyone attaches to a work package lands; the
OpenProject container chowns that one to its internal `app` user on first start, so do not be
surprised in step 7 when its owner is no longer you.
## 3. Secrets
Three secrets, all generated on the server. `SECRET_KEY_BASE` signs sessions and derives the key
for encrypted database columns, `OPENPROJECT_SEED_ADMIN_USER_PASSWORD` replaces the password the
seeder would otherwise put on the `admin` account, and `DB_PASSWORD` is the PostgreSQL password.
Replace `<DOMAIN>` on the first line with your hostname before you paste.
```bash
umask 077
cat > /srv/openproject/.env <<EOF
OPENPROJECT_HOST__NAME=<DOMAIN>
OPENPROJECT_HTTPS=true
SECRET_KEY_BASE=$(openssl rand -hex 64)
OPENPROJECT_SEED_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/openproject/.env
umask 022
ls -l /srv/openproject/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
Do not paste that file, any of the three values, or any command output containing them into this
chat window. Read the admin password once, in step 7, with
`sudo grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD /srv/openproject/.env`, and put it straight into
your password manager rather than into a message.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/openproject/.env` and
carry on. Hex rather than base64 is deliberate for all three: one value travels inside a
database connection string, and OpenProject parses environment values as YAML, where base64
punctuation is a hazard. If the file already existed from an earlier attempt, this block has
overwritten all three, which is fine before the database exists and a problem afterwards: the
database keeps the password it was created with, and the old `SECRET_KEY_BASE` is what decrypts
the encrypted columns already in it.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/openproject/compose.yml <<'EOF'
# OpenProject · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://www.openproject.org/docs/installation-and-operations/installation/docker/
# docker compose ..... https://www.openproject.org/docs/installation-and-operations/installation/docker-compose/
# configuration ...... https://www.openproject.org/docs/installation-and-operations/configuration/
# health endpoints ... https://www.openproject.org/docs/installation-and-operations/operation/monitoring/
#
# Two services. The all-in-one image runs Puma, the worker, memcached, the
# collaborative-editing server and an Apache under one supervisord, so
# upstream's nine-service compose file for the slim image collapses to one
# container here. It starts its own PostgreSQL only when DATABASE_URL points
# at 127.0.0.1; ours points at the postgres service below. Upstream supports
# PostgreSQL 16 and above. Digests read on 2026-08-06; both images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: openproject-db
restart: unless-stopped
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/openproject/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openproject -d openproject"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
openproject:
image: openproject/openproject:17.7.1@sha256:bbaaedbe3837097dd189f739565064accb731d23cd87294a21bd07e0be010f6a
container_name: openproject
restart: unless-stopped
# Hostname, HTTPS flag, SECRET_KEY_BASE and the seeded admin password all
# arrive from /srv/openproject/.env, mode 600 on the host.
env_file: /srv/openproject/.env
environment:
# No query string: the all-in-one start-up script hands DATABASE_URL to
# psql through a shell, where `&` would background the command.
DATABASE_URL: postgres://openproject:${DB_PASSWORD}@postgres/openproject
RAILS_MIN_THREADS: "4"
RAILS_MAX_THREADS: "16"
# Inbound mail off: no IMAP poller, no cron process for it.
IMAP_ENABLED: "false"
volumes:
- /srv/openproject/assets:/var/openproject/assets
healthcheck:
# Migrations and the seeder run before Apache exists: long start period.
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health_checks/default || exit 1"]
interval: 15s
retries: 20
start_period: 600s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8116.
- "127.0.0.1:8116:80"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/openproject && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/openproject/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/openproject/compose.yml` and paste again in one go. Do not add a `SERVER_NAME`
variable to this file, whatever else you read: left unset, the Apache inside the image renders
one catch-all site that answers on any hostname, and set, it renders a second site that returns
a plain warning page to every request whose Host does not match, including the container's own
health check on 127.0.0.1.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-openproject
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# OpenProject · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.openproject.org/docs/installation-and-operations/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also OPENPROJECT_HOST__NAME in .env, which OpenProject builds every link and
# form action from.
<DOMAIN> {
# The Angular bundle and the work package tables are worth compressing.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8116 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. reverse_proxy carries
# the /hocuspocus WebSocket upgrade with no extra directive.
reverse_proxy 127.0.0.1:8116
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-openproject /etc/caddy/Caddyfile`,
reload, and paste again. Caddy terminates TLS and speaks plain http to the container on 8116,
and it sets `X-Forwarded-Proto: https` on every proxied request, which is what lets
`OPENPROJECT_HTTPS=true` stay in your .env without OpenProject deciding the connection was
insecure and redirecting you in a loop.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8116` or `5432`.
If you do not: delete anything for `8116` or `5432` with `sudo ufw delete allow 8116`. 8116 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp redirects to HTTPS and answers the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.
## 7. Start and verify
The container runs every migration and then the seeder before Apache starts. The loop below
waits up to ten minutes for that, and on a small box it will use several of them.
```bash
cd /srv/openproject
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health_checks/default); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health_checks/default
curl -sS https://<DOMAIN>/login | grep -o '<h1>Sign in</h1>'
docker compose exec -T openproject bundle exec rails runner 'puts "shipped-default-still-works=" + User.find_by(login: "admin").check_password?("admin").to_s'
```
You should see, in order: the loop reaching `200`, a short line containing `PASSED`, then
`<h1>Sign in</h1>`, then `shipped-default-still-works=false`.
If you do not: that last line is the one that decides whether this install is safe to leave
running. Upstream seeds an account with login `admin` and password `admin`, step 3 replaced that
password with a generated one before the seeder ever ran, and `false` is the proof it worked. If
it prints `true`, stop: a password published in upstream's documentation is live on a public
hostname. If the loop never reaches `200`, run `docker compose logs --tail 20 postgres` first,
because a database that never reports healthy is step 2 done wrong, then
`docker compose logs --tail 60 openproject`; migration lines still scrolling mean you stopped
too early rather than anything being broken. A page of warning text about a domain instead of
`<h1>Sign in</h1>` means a `SERVER_NAME` variable got into the compose file. A running container
is not success.
The first screen at https://<DOMAIN>/login shows the heading `Sign in` over a username box, a
password box and a sign-in button.
Now read your admin password and use it:
```bash
sudo grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD /srv/openproject/.env
```
You should see: one line, one long hex value. Put it in your password manager, then open
https://<DOMAIN>/login in a browser, sign in as `admin` with that value, and set your own
password when OpenProject asks you to.
If you do not: OpenProject forces that password change on the first sign-in, so a screen
demanding a new password is the install working rather than failing. The seeded account carries
the address `admin@example.net`, which you change under your own profile once you are in. Do not
paste the hex value into this chat window.
## 8. First backup and restore
Two artifacts. The database holds every project, work package, comment and user. The file
archive holds the attachments plus the three files that rebuild the service around them.
```bash
cd /srv/openproject
docker compose exec -T postgres pg_dump -U openproject -d openproject -x -O | gzip > /srv/openproject/backups/openproject-db-$(date +%F).sql.gz
sudo tar -czf /srv/openproject/backups/openproject-files-$(date +%F).tar.gz -C /srv/openproject compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh /srv/openproject/backups/
```
You should see: two files, the dump a few hundred kilobytes on a fresh install and the archive a
few kilobytes. Nothing goes offline: `pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/openproject
scp vps:/srv/openproject/backups/* ~/backups/openproject/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/openproject/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty install:
```bash
cd /srv/openproject
docker compose down
sudo rm -rf /srv/openproject/postgres
sudo install -d -m 700 /srv/openproject/postgres
sudo tar -xzf /srv/openproject/backups/openproject-files-$(date +%F).tar.gz -C /srv/openproject compose.yml .env assets
docker compose up -d postgres
sleep 30
gunzip -c /srv/openproject/backups/openproject-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U openproject -d openproject
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health_checks/default); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then the loop reaching `200` again,
and your admin password still signing you in.
If you do not: `role "openproject" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. The order in that block is the
lesson: PostgreSQL takes its password from .env the moment it initialises an empty directory,
and the `SECRET_KEY_BASE` in that same file decrypts the encrypted columns inside the dump, so a
database restored without its .env is one nobody can read.
## 9. Updating later
New versions are listed at https://github.com/opf/openproject/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/openproject/compose.yml to the new tag and
its digest.
```bash
cd /srv/openproject
docker compose pull
docker compose up -d
docker compose logs --tail 40 openproject
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. A minor-version
jump migrates the database and can take as long as the first boot did, so give the log time
before you decide it has failed, then re-run the health check from step 7. Read the release
notes before crossing a major version: those carry migration steps this prompt does not.
## 10. What will probably go wrong
You will think the install has hung. I did. After `docker compose up -d` the container runs every
migration and then the seeder, and until that finishes there is no Apache inside it, so
https://<DOMAIN> returns a Caddy `502` while `docker compose ps` shows a healthy database beside
an app doing nothing. On a 4 GB box it was over four minutes before the first `200`, and I had
already opened the compose file twice looking for a mistake that was not there. Let the loop in
step 7 run all sixty attempts before touching anything; `docker compose logs -f openproject`
prints each migration as it lands.
## 11. Out of scope
- Do not configure SMTP. OpenProject runs without it, so every notification it would have
emailed stays inside the web interface.
- Do not set `IMAP_ENABLED` or configure inbound mail. Creating work packages by email needs a
mailbox you own and poll, a separate decision from this install.
- Do not switch to the `-slim` image or split this into upstream's nine-service compose file.
The all-in-one container is the shape this prompt installs.
- Do not install the BIM edition. It is a different image, amd64 only, and it is for
construction models rather than project management.316 lines · 14,986 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install OpenProject 17.7.1, with the PostgreSQL it stores every project in, under
~/selfhost/openproject, answering at http://localhost:8116.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
OpenProject answers at http://localhost:8116, this computer and nowhere else, so nobody they
invite to a project can reach it, nor their own phone, and it runs only while the machine is
awake. On this path it is a planner for one person, not a place a team meets.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Upstream publishes a floor of 4096 MB of
RAM and 20 GB of disk; both images publish amd64 and arm64. On macOS and Windows that figure is
the host's and Docker Desktop takes its share out of it, so check it is allowed 4 GB. If RAM is
under 4096 MB or disk under 20 GB, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/openproject/assets ~/selfhost/openproject/backups
ls -la ~/selfhost/openproject
```
Assert: `ls -la` shows `assets` and `backups`, owned by the user. `assets` holds every file
attached to a work package; the container chowns it to its `app` user, so its owner changing in
step 7 is expected. Upstream notes macOS can refuse that chown outside the user's own home,
which is why this layout stays inside it. The database has no folder: step 5 keeps it in a
volume Docker manages.
## 4. Secrets
Three secrets, generated here. `SECRET_KEY_BASE` signs sessions and derives the key for
encrypted columns, `OPENPROJECT_SEED_ADMIN_USER_PASSWORD` replaces the password the seeder would
put on the `admin` account, and `DB_PASSWORD` is the PostgreSQL password. Hex, not
base64: OpenProject parses environment values as YAML, where base64 punctuation is a hazard.
Print none of them and keep all three out of your summary and every log line.
```bash
umask 077
cat > ~/selfhost/openproject/.env <<EOF
OPENPROJECT_HOST__NAME=localhost:8116
OPENPROJECT_HTTPS=false
SECRET_KEY_BASE=$(openssl rand -hex 64)
OPENPROJECT_SEED_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/openproject/.env
umask 022
ls -l ~/selfhost/openproject/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three. `OPENPROJECT_HTTPS=false` is required here and only here: nothing on this
machine terminates TLS, and at its default OpenProject redirects every request to an address
that does not exist. On Windows those mode bits are advisory; the real boundary is the user's
own account.
## 5. compose.yml
```bash
cat > ~/selfhost/openproject/compose.yml <<'EOF'
# OpenProject · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://www.openproject.org/docs/installation-and-operations/installation/docker/
#
# Two services, every path relative to ~/selfhost/openproject/ so one file
# works on macOS, Linux and Windows. The database is a named volume because the
# PostgreSQL image chowns its data directory to a uid a Windows home bind
# cannot grant; attachments stay a bind mount, visible in Finder or Explorer.
# The all-in-one image runs Puma, the worker, memcached, the collaborative
# editing server and an Apache under one supervisord, and starts no PostgreSQL
# of its own. Digests read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: openproject-db
restart: unless-stopped
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- openproject-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openproject -d openproject"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
openproject:
image: openproject/openproject:17.7.1@sha256:bbaaedbe3837097dd189f739565064accb731d23cd87294a21bd07e0be010f6a
container_name: openproject
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgres://openproject:${DB_PASSWORD}@postgres/openproject
RAILS_MIN_THREADS: "4"
RAILS_MAX_THREADS: "16"
IMAP_ENABLED: "false"
volumes:
- ./assets:/var/openproject/assets
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health_checks/default || exit 1"]
interval: 15s
retries: 20
start_period: 600s
ports:
# Loopback only: no other device on the wifi can reach 8116.
- "127.0.0.1:8116:80"
depends_on:
postgres:
condition: service_healthy
volumes:
openproject-pgdata:
EOF
cd ~/selfhost/openproject && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Do not set `SERVER_NAME`: left unset, the Apache inside the
image answers on any hostname, which is what makes `localhost` and the container's own health
check both work.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context, so the editor still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8116 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. Confirm it:
```bash
grep -n '127.0.0.1:8116' ~/selfhost/openproject/compose.yml
```
Assert: one line, `- "127.0.0.1:8116:80"`. PostgreSQL publishes no host port, so 5432 cannot
appear at all.
## 7. Start and verify
The container runs every migration and then the seeder before Apache starts, so the first boot
takes minutes. The loop allows ten of them.
```bash
cd ~/selfhost/openproject
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8116/health_checks/default); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8116/health_checks/default
curl -sS http://localhost:8116/login | grep -o '<h1>Sign in</h1>'
docker compose exec -T openproject bundle exec rails runner 'puts "shipped-default-still-works=" + User.find_by(login: "admin").check_password?("admin").to_s'
```
Assert all four and print what you received: the loop ends on `200`; the health response
contains `PASSED`; the third prints `<h1>Sign in</h1>`, the first screen's heading; the fourth
prints `shipped-default-still-works=false`, the security assert here, because upstream
seeds login `admin` with password `admin` and step 4 replaced it. If it prints `true`, stop and
say so.
If any of the four misses, stop, run `docker compose logs --tail 60 openproject` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 4, where an empty `DB_PASSWORD` leaves PostgreSQL refusing to
start; a log still in migrations wants more time. If `port is already allocated`
came back, find what holds 8116 (`lsof -nP -iTCP:8116 -sTCP:LISTEN`, or
`netstat -ano | findstr :8116` on Windows) and stop until the user frees it. A running container
is not success.
STOP: tell the user to read the seeded password with
`grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD ~/selfhost/openproject/.env`, put it in their
password manager, sign in at http://localhost:8116/login as `admin`, and wait. Do not continue
until they confirm. OpenProject forces a password change on that first sign-in; the seeded value
exists so no account here answers to a password printed in upstream's docs.
## 8. First backup and restore
Two artifacts: a database dump with every project, work package, comment and user, and a file
archive with the attachments and the two files that rebuild the service.
```bash
cd ~/selfhost/openproject
docker compose exec -T postgres pg_dump -U openproject -d openproject -x -O | gzip > ~/selfhost/openproject/backups/openproject-db-$(date +%F).sql.gz
tar -C ~/selfhost/openproject -czf ~/selfhost/openproject/backups/openproject-files-$(date +%F).tar.gz compose.yml .env assets
ls -lh ~/selfhost/openproject/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.
Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`; in Git Bash a Windows drive is
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames there, or say plainly
this install has no backup.
To restore, in this order. `cd ~/selfhost/openproject`, untar the file archive there first so
compose.yml and .env are back before any container starts: PostgreSQL takes `DB_PASSWORD` from
.env when it initialises an empty volume, and `SECRET_KEY_BASE` decrypts the encrypted columns
in the dump, so a database restored without its .env is one nobody can read. Then
`docker compose down -v`, which drops the old volume on purpose,
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U openproject -d openproject`, then
`docker compose up -d` and wait for step 7's loop. That is the whole disaster plan.
## 9. Updating later
New versions are listed at https://github.com/opf/openproject/releases. Take both backups first,
then edit the image line in compose.yml to the new tag and digest:
```bash
cd ~/selfhost/openproject
docker compose pull
docker compose up -d
docker compose logs --tail 40 openproject
```
A minor-version jump migrates the database and can take as long as the first boot. Watch that
log until it settles, then re-run step 7's health check. Read the release notes before crossing
a major version: those carry steps this prompt does not.
## 10. What will probably go wrong
I closed the lid with a work package half written, opened it an hour later, and got a browser
error that read like a lost database. It was not: the machine had suspended both containers with
itself, and OpenProject needed a minute after the wake to answer. Where it really is gone is
after a reboot, because `restart: unless-stopped` acts only once the Docker daemon is up and
Docker Desktop starts with the session only if that setting is on. Turn it on, and after a
reboot run `cd ~/selfhost/openproject && docker compose up -d`.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `OPENPROJECT_HOST__NAME` to this machine's LAN address and do not rebind 8116 to
0.0.0.0 so a colleague can reach it. That puts a Rails application on every network the user
joins.
- Do not configure SMTP. OpenProject runs without it, so every notification it would have
emailed stays in the web interface.
- Do not switch to the `-slim` image or split this into upstream's nine-service compose file.compose.local.ymlthe services, pinned · local layout57 lines
# OpenProject · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://www.openproject.org/docs/installation-and-operations/installation/docker/
#
# Two services, every path relative to ~/selfhost/openproject/ so one file
# works on macOS, Linux and Windows. The database is a named volume because the
# PostgreSQL image chowns its data directory to a uid a Windows home bind
# cannot grant; attachments stay a bind mount, visible in Finder or Explorer.
# The all-in-one image runs Puma, the worker, memcached, the collaborative
# editing server and an Apache under one supervisord, and starts no PostgreSQL
# of its own. Digests read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: openproject-db
restart: unless-stopped
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- openproject-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openproject -d openproject"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
openproject:
image: openproject/openproject:17.7.1@sha256:bbaaedbe3837097dd189f739565064accb731d23cd87294a21bd07e0be010f6a
container_name: openproject
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgres://openproject:${DB_PASSWORD}@postgres/openproject
RAILS_MIN_THREADS: "4"
RAILS_MAX_THREADS: "16"
IMAP_ENABLED: "false"
volumes:
- ./assets:/var/openproject/assets
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health_checks/default || exit 1"]
interval: 15s
retries: 20
start_period: 600s
ports:
# Loopback only: no other device on the wifi can reach 8116.
- "127.0.0.1:8116:80"
depends_on:
postgres:
condition: service_healthy
volumes:
openproject-pgdata:agent-readable mirror: /self-host/asana.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned63 lines
# OpenProject · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://www.openproject.org/docs/installation-and-operations/installation/docker/
# docker compose ..... https://www.openproject.org/docs/installation-and-operations/installation/docker-compose/
# configuration ...... https://www.openproject.org/docs/installation-and-operations/configuration/
# health endpoints ... https://www.openproject.org/docs/installation-and-operations/operation/monitoring/
#
# Two services. The all-in-one image runs Puma, the worker, memcached, the
# collaborative-editing server and an Apache under one supervisord, so
# upstream's nine-service compose file for the slim image collapses to one
# container here. It starts its own PostgreSQL only when DATABASE_URL points
# at 127.0.0.1; ours points at the postgres service below. Upstream supports
# PostgreSQL 16 and above. Digests read on 2026-08-06; both images publish
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
container_name: openproject-db
restart: unless-stopped
environment:
POSTGRES_DB: openproject
POSTGRES_USER: openproject
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/openproject/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openproject -d openproject"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
openproject:
image: openproject/openproject:17.7.1@sha256:bbaaedbe3837097dd189f739565064accb731d23cd87294a21bd07e0be010f6a
container_name: openproject
restart: unless-stopped
# Hostname, HTTPS flag, SECRET_KEY_BASE and the seeded admin password all
# arrive from /srv/openproject/.env, mode 600 on the host.
env_file: /srv/openproject/.env
environment:
# No query string: the all-in-one start-up script hands DATABASE_URL to
# psql through a shell, where `&` would background the command.
DATABASE_URL: postgres://openproject:${DB_PASSWORD}@postgres/openproject
RAILS_MIN_THREADS: "4"
RAILS_MAX_THREADS: "16"
# Inbound mail off: no IMAP poller, no cron process for it.
IMAP_ENABLED: "false"
volumes:
- /srv/openproject/assets:/var/openproject/assets
healthcheck:
# Migrations and the seeder run before Apache exists: long start period.
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/health_checks/default || exit 1"]
interval: 15s
retries: 20
start_period: 600s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8116.
- "127.0.0.1:8116:80"
depends_on:
postgres:
condition: service_healthyCaddyfilethe hostname and TLS28 lines
# OpenProject · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.openproject.org/docs/installation-and-operations/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also OPENPROJECT_HOST__NAME in .env, which OpenProject builds every link and
# form action from.
<DOMAIN> {
# The Angular bundle and the work package tables are worth compressing.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8116 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. reverse_proxy carries
# the /hocuspocus WebSocket upgrade with no extra directive.
reverse_proxy 127.0.0.1:8116
}install.shthe same install, no agent157 lines
#!/usr/bin/env bash
# OpenProject · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=projects.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://www.openproject.org/docs/installation-and-operations/installation/docker/
# https://www.openproject.org/docs/installation-and-operations/installation/docker-compose/
# https://www.openproject.org/docs/installation-and-operations/configuration/
# https://www.openproject.org/docs/installation-and-operations/operation/monitoring/
#
# Three secrets are generated here, on this machine: SECRET_KEY_BASE, the
# password the seeder puts on the admin account, and the PostgreSQL password.
# All three go into /srv/openproject/.env with mode 600 and none is printed.
#
# DOMAIN_HOST is also OPENPROJECT_HOST__NAME, the name OpenProject builds every
# link and form action from. Upstream warns that a container reached on a name
# it was not told about is open to Host header injection.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/openproject}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. projects.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; upstream's floor for a single-server install is 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; upstream's floor is 20 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/assets"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64 for all three: one value travels inside a database
# connection string, and OpenProject parses environment values as YAML, where
# base64 punctuation is a hazard. Read the admin password later with
# sudo grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD /srv/openproject/.env
# SECRET_KEY_BASE has to stay the same across restarts: it derives the key for
# the encrypted columns in the database, so a dump restored without this file
# is a dump nobody can read.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
OPENPROJECT_HOST__NAME=${DOMAIN_HOST}
OPENPROJECT_HTTPS=true
SECRET_KEY_BASE=$(openssl rand -hex 64)
OPENPROJECT_SEED_ADMIN_USER_PASSWORD=$(openssl rand -hex 24)
DB_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-openproject"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8116 nor 5432 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8116 and 5432 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The container runs every database migration and then the seeder before the
# Apache inside it exists, so the first boot takes minutes and Caddy answers
# 502 for all of them. Ten minutes of patience is budgeted below.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/health_checks/default (this takes minutes on a first boot)"
for _ in $(seq 1 60); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health_checks/default" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/health_checks/default answered ${code:-nothing}. Check: docker compose logs --tail 60 openproject"
curl -sS "https://${DOMAIN_HOST}/health_checks/default" | grep -q 'PASSED' \
|| die "the health endpoint answered 200 without PASSED. Check: docker compose logs --tail 60 openproject"
curl -sS "https://${DOMAIN_HOST}/login" | grep -q '<h1>Sign in</h1>' \
|| die "the login page did not contain the Sign in heading. A warning page here means SERVER_NAME reached the container."
# The seeded admin must not answer to the password upstream's docs print. The
# seeder read OPENPROJECT_SEED_ADMIN_USER_PASSWORD from .env before it ran, so
# this should already be false; if it is not, the install is not safe to leave.
default_works="$(docker compose exec -T openproject bundle exec rails runner 'puts User.find_by(login: "admin").check_password?("admin").to_s' | tr -d '\r' | tail -1)"
[ "$default_works" = "false" ] || die "the admin account still answers to the password upstream documents. Stop and investigate."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U openproject -d openproject -x -O | gzip > "$APP_DIR/backups/openproject-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/openproject-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env assets -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/openproject-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
OpenProject is answering at https://${DOMAIN_HOST}/login
1. Sign in as admin. Your generated password is in $APP_DIR/.env,
mode 600. Read it with
sudo grep OPENPROJECT_SEED_ADMIN_USER_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here, and
the account no longer answers to the password upstream documents.
2. OpenProject will make you set your own password on that first
sign-in. That screen is the install working, not failing. The
seeded account carries admin@example.net; change it in your profile.
3. No mail is configured. Every notification OpenProject would have
emailed stays inside the web interface until you set up SMTP.
4. First backup written to $APP_DIR/backups: a database dump and a
file archive with the attachments, compose.yml, .env and the Caddy
site block. They are on the same disk as the data, which is not a
backup. Copy them somewhere else tonight, and keep .env with them:
SECRET_KEY_BASE is what decrypts the encrypted columns in the dump.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Asana.
- This is not Asana's texture. OpenProject is classic project management: work packages with types, statuses and a work breakdown structure, Gantt charts, time and cost tracking. Creating one item takes more clicks than adding a line to a list, and a team that liked Asana for being light will notice on day one.
- No email. This install configures no SMTP, so every notification OpenProject would have sent about an assignment, a mention or a due date stays inside the web interface and nothing lands in anyone's inbox. On a team install that is the first thing you will want to fix, and it means running or renting a mail sender.
- You own a PostgreSQL and one irreplaceable secret. The database holds every project, work package and comment, and the SECRET_KEY_BASE in .env derives the key for the encrypted columns inside a dump, so a backup taken without that file restores into something nobody can read.
- The first boot is slow enough to look broken. Migrations and the seeder both run before anything answers, which took over four minutes on a 4 GB box, and every version bump repeats some of it.
- You get the Community edition, which is the GPL-3 code. OpenProject sells Enterprise add-ons on top of the same install: single sign-on with OIDC or SAML, LDAP group sync, baseline comparisons, portfolio and resource management. Those stay locked here, and the interface says so where they would have been.
Where this came from
“the same value must be reused on every container start, otherwise existing sessions and encrypted database content become unreadable.”
- Upstream's own docker-compose file runs nine services, because it is built around the slim image; the all-in-one image this install uses runs the web process, the worker, memcached and the collaborative-editing server under one supervisord instead. source
- The all-in-one container starts a PostgreSQL of its own only when DATABASE_URL points at 127.0.0.1, so pointing that variable at a separate database service leaves the bundled one unused. source
- A fresh install seeds an account with login admin and password admin, and OPENPROJECT_SEED_ADMIN_USER_PASSWORD sets that password before the seeder runs. source
- Upstream states a minimum of 4096 MB of RAM and 20 GB of disk for a single-server install, PostgreSQL 16 or above, and Docker images for both amd64 and arm64. source
- The application publishes /health_checks/default as an application-level check that the web workers are running, which is what this install asserts on instead of a green container. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Asana?
Not Asana itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is OpenProject. Classic project management with work packages, Gantt charts and time tracking, on a server you own and with no per-seat bill. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 150 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Asana?
OpenProject. Classic project management with work packages, Gantt charts and time tracking, on a server you own and with no per-seat bill. The honest swap for a team that had outgrown Asana's list rather than one that loved it. You get work packages with types and statuses, Gantt charts, a work breakdown structure, time and cost tracking, and no seat counter, all from one container plus a database. What you give up is the lightness: OpenProject asks more of every task you create, and its notifications go nowhere until you configure mail, which Asana does for you. OpenProject is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Asana?
4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. OpenProject itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Asana Starter, $67.45/mo — $809.40 a year, 5 seats assumed.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the OpenProject install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run OpenProject on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same OpenProject on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8116, which resolves on this computer and nowhere else, so nobody you invite to a project can open it and neither can your phone: on the local path this is a planner for one person. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.