OpenProject is a Ruby on Rails application that covers classic project management, agile boards, Gantt scheduling, time and cost tracking, a wiki, and BIM. It is the heaviest of the mainstream open source project management platforms, and it behaves accordingly on a VPS. Treat this as a Rails production deployment, not a one-container app.
This guide deploys OpenProject Community Edition with Docker Compose on a RamNode KVM VPS, terminates TLS at a host Nginx instance, and covers backups, upgrades, and the tuning you need to keep the stack inside a modest memory budget.
1. Pick the Right RamNode Plan
OpenProject runs a Puma web process, a background worker, a Sidekiq-style cron container, PostgreSQL, memcached, a Caddy proxy, and a collaborative editing server. Undersize the VPS and the first symptom is the worker container getting OOM-killed during seeding.
| Users | vCPU | RAM | Disk | Notes |
|---|---|---|---|---|
| Evaluation / 1 to 5 | 2 | 4 GB | 40 GB | Works, but expect slow asset serving on first boot. Add swap. |
| 10 to 30 active | 4 | 8 GB | 80 GB | Recommended baseline. |
| 50+ active, heavy attachments | 4 to 6 | 16 GB | 160 GB+ | Move attachments to S3 or object storage. |
Do not attempt this on a 2 GB plan. The Rails asset precompile and database seeding steps alone will exceed it.
Order a KVM plan rather than an OpenVZ or container plan. OpenProject needs a real kernel for Docker and cgroup limits.
Storage note: the PostgreSQL volume and the attachments volume grow independently. Attachments are the usual surprise. Budget 5 to 10 GB per year for a 20 person team that attaches documents to work packages.
2. Prerequisites
- RamNode KVM VPS running Ubuntu 24.04 LTS
- A non-root user with sudo
- A DNS A record pointing
projects.example.comat the VPS IPv4 address, and an AAAA record if you use IPv6 - Ports 80 and 443 reachable
Set the hostname and timezone first:
sudo hostnamectl set-hostname projects
sudo timedatectl set-timezone UTCUpdate and install the basics:
sudo apt update && sudo apt -y upgrade
sudo apt -y install curl ca-certificates git ufw nginxAdd swap
Add swap even on an 8 GB plan. Rails allocates aggressively during boot and asset compilation, then releases. Swap turns a hard OOM kill into a slow minute.
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
sudo sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.confFirewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableDocker publishes ports by writing directly to the DOCKER-USER iptables chain, which bypasses UFW. This guide binds the OpenProject proxy to 127.0.0.1 so nothing is exposed regardless. Verify with sudo ss -tlnp after startup and confirm nothing but sshd and nginx listens on a public address.
3. Install Docker Engine
Use the upstream Docker repository, not the docker.io package from Ubuntu.
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
newgrp dockerConfirm:
docker compose version4. Clone the Compose Recipe
OpenProject maintains the compose recipes in opf/openproject-docker-compose. Older documentation and blog posts reference opf/openproject-deploy. That repository has been renamed. Use the new name and pin to a stable branch rather than main.
sudo mkdir -p /opt/openproject
sudo chown "$USER":"$USER" /opt/openproject
cd /opt
git clone https://github.com/opf/openproject-docker-compose.git \
--depth=1 --branch=stable/17 openproject
cd /opt/openprojectCheck what the branch actually is before you commit to it:
git ls-remote --heads https://github.com/opf/openproject-docker-compose.git 'stable/*'Pick the highest stable/NN that matches a current OpenProject release. Never track main on a production instance.
Create the assets directory
The compose file mounts host assets by default when OPDATA is set to a path. Create it with the correct ownership. The application container runs as UID 1000.
sudo mkdir -p /var/openproject/assets
sudo chown -R 1000:1000 /var/openproject/assets5. Configure the Environment
cd /opt/openproject
cp .env.example .envGenerate the secrets before you edit:
openssl rand -hex 64 # SECRET_KEY_BASE
openssl rand -hex 32 # COLLABORATIVE_SERVER_SECRET
openssl rand -base64 24 | tr -d '/+=' # database passwordEdit .env:
nano .envSet at minimum:
# Image tag. Match this to the stable branch you cloned.
TAG=17
# Bind the stack's proxy to loopback only. Host Nginx fronts it.
PORT=127.0.0.1:8080
# Canonical hostname. OpenProject builds absolute URLs from this.
OPENPROJECT_HOST__NAME=projects.example.com
# Terminating TLS at Nginx, but OpenProject must know the public scheme is https.
OPENPROJECT_HTTPS=true
# Rails secret. Changing this invalidates every session and 2FA remember token.
SECRET_KEY_BASE=<output of openssl rand -hex 64>
# Collaborative editing (hocuspocus). The default is a placeholder. Override it.
COLLABORATIVE_SERVER_SECRET=<output of openssl rand -hex 32>
COLLABORATIVE_SERVER_URL=wss://projects.example.com/hocuspocus
# Database. Change the password from the shipped default.
DATABASE_URL=postgres://postgres:<strong-password>@db/openproject?pool=20&encoding=unicode&reconnect=true
POSTGRES_PASSWORD=<same strong password>
# Host path for attachments and other assets.
OPDATA=/var/openproject/assets
# Rails threading. Tune these to your plan. See section 10.
RAILS_MIN_THREADS=2
RAILS_MAX_THREADS=6
# Hide the version badge that phones home.
OPENPROJECT_SECURITY__BADGE__DISPLAYED=false
# No inbound mail on RamNode. Keep this off.
IMAP_ENABLED=false
OPENPROJECT_DEFAULT__LANGUAGE=enLock the file down. It holds your database password and Rails secret.
chmod 600 .envOn SECRET_KEY_BASE: treat this like a password and back it up alongside the database. Restoring a database dump against a different SECRET_KEY_BASE logs out every user and breaks 2FA remember tokens. It is not catastrophic, but it is a surprise you do not want during a restore drill.
6. First Boot
Bring the stack up. The proxy image is built locally, so pass --build.
cd /opt/openproject
docker compose up -d --build --pull alwaysYou will see pull access denied for openproject/proxy. Ignore it. That image is built from the repo, not pulled. If it breaks a scripted pull, use:
docker compose pull --ignore-buildableFirst boot takes several minutes. The seeder container populates the database with default types, statuses, and workflows. Watch it:
docker compose logs -f seeder
docker compose logs -f webWait for the web container to report healthy:
docker compose psThe health check hits /health_checks/default on port 8080 inside the container. Once it is healthy:
curl -I -H 'Host: projects.example.com' http://127.0.0.1:8080/A 302 to /login means the stack is alive. You cannot browse it yet because nothing on port 80 or 443 is serving it. That is next.
If the seeder fails on a 4 GB plan
Exit code 137 is the OOM killer. Confirm swap is active with free -h, then rerun:
docker compose up -d seeder7. Nginx Reverse Proxy and TLS
OpenProject's bundled Caddy proxy does not terminate TLS. Put Nginx in front of it.
Get a certificate first using the standalone challenge while Nginx is stopped, or use webroot. Standalone is simpler for a first issue:
sudo apt -y install certbot python3-certbot-nginx
sudo systemctl stop nginx
sudo certbot certonly --standalone -d projects.example.com \
--agree-tos -m admin@example.com --no-eff-emailIf port 80 is blocked upstream or you are behind Cloudflare, use DNS-01 instead:
sudo apt -y install python3-certbot-dns-cloudflare
echo "dns_cloudflare_api_token = YOUR_TOKEN" | sudo tee /etc/letsencrypt/cloudflare.ini
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d projects.example.com --agree-tos -m admin@example.com --no-eff-emailWrite the server block:
sudo nano /etc/nginx/sites-available/openprojectupstream openproject {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 80;
listen [::]:80;
server_name projects.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name projects.example.com;
ssl_certificate /etc/letsencrypt/live/projects.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/projects.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Work package attachments. Raise this if your team uploads large files.
client_max_body_size 256m;
# Long-running exports and PDF generation.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
location / {
proxy_pass http://openproject;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
# Collaborative editing websockets.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
}
}The $connection_upgrade variable is not defined by default. Add a map:
sudo nano /etc/nginx/conf.d/upgrade-map.confmap $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Enable and reload:
sudo ln -s /etc/nginx/sites-available/openproject /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl start nginxCertbot's systemd timer handles renewal. Add a deploy hook so Nginx picks up the new certificate:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
echo -e '#!/bin/sh\nsystemctl reload nginx' \
| sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-runCaddy and forwarded headers: OpenProject's internal Caddy proxy is configured to forward X-Forwarded-* headers it receives rather than setting them itself. That means it trusts whatever is in front of it. Since Nginx on the same host is the only thing that can reach 127.0.0.1:8080, and Nginx overwrites those headers on every request, this is safe here. It would not be safe if you exposed port 8080 publicly. Do not.
8. Post-Install Hardening
Browse to https://projects.example.com. Log in with admin / admin.
Change the password immediately. OpenProject forces this on first login, but confirm.
Then work through Administration:
Authentication settings:
- Set self-registration to "disabled" or "manual account activation". The default allows anyone who finds the URL to create an account.
- Enable 2FA and consider enforcing it for administrators.
- Set a session expiry. The default is generous.
- Set the password policy: minimum length, character classes, and ban list.
Users and permissions:
- Review the default roles. The "Member" role is broad.
- Restrict who can create projects.
System settings:
- Set the host name to
projects.example.comif it did not pick it up from the environment. - Disable the news feed and update check if you do not want outbound calls.
Brute force protection:
- Administration then Authentication then Authentication settings. Set failed login attempts before block and the block duration.
Verify the admin seeding took effect and no default accounts remain:
docker compose exec web bundle exec rails runner \
'puts User.where(admin: true).pluck(:login).inspect'9. Outbound Email
RamNode does not permit running mail services on their VPS plans, and port 25 outbound is not something to build on regardless. OpenProject needs email for notifications, invitations, and password resets, so use an external SMTP relay: Postmark, SendGrid, Mailgun, Amazon SES, or your own relay elsewhere.
Add to .env:
EMAIL_DELIVERY_METHOD=smtp
SMTP_ADDRESS=smtp.postmarkapp.com
SMTP_PORT=587
SMTP_DOMAIN=example.com
SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS_AUTO=true
SMTP_USER_NAME=<relay-username>
SMTP_PASSWORD=<relay-password>
OPENPROJECT_MAIL__FROM=openproject@example.comRecreate the app containers:
docker compose up -dSend a test from Administration then Emails and notifications then Email notifications.
If delivery hangs, the container usually cannot resolve DNS. Check:
docker compose exec web getent hosts smtp.postmarkapp.comLeave IMAP_ENABLED=false. Inbound mail processing requires a mailbox to poll and adds a cron container you do not need.
10. Tuning for a Small VPS
The default thread and worker counts assume more RAM than a 4 GB plan has.
Create docker-compose.override.yml. Never edit docker-compose.yml directly, since git pull overwrites it.
nano /opt/openproject/docker-compose.override.ymlservices:
web:
environment:
RAILS_MIN_THREADS: 2
RAILS_MAX_THREADS: 6
# One Puma worker on 4GB. Two on 8GB.
WEB_CONCURRENCY: 1
deploy:
resources:
limits:
memory: 1600M
worker:
deploy:
resources:
limits:
memory: 1200M
db:
command: >
postgres
-c shared_buffers=256MB
-c effective_cache_size=1GB
-c work_mem=8MB
-c maintenance_work_mem=128MB
-c max_connections=50Apply:
cd /opt/openproject
docker compose down && docker compose up -dWatch actual usage before you tighten limits further:
docker stats --no-streamRules of thumb:
WEB_CONCURRENCYmultiplies memory. Each Puma worker is a full Rails process.RAILS_MAX_THREADStimesWEB_CONCURRENCYmust stay below thepool=20inDATABASE_URL, or you will see connection pool timeouts.- PostgreSQL
shared_buffersat 25 percent of the container limit is a reasonable start.
Move attachments off local disk
If attachments outgrow the plan, point OpenProject at S3-compatible storage. RamNode does not offer object storage, so use an external provider. Add to docker-compose.override.yml under web and worker:
OPENPROJECT_ATTACHMENTS__STORAGE: fog
OPENPROJECT_FOG_DIRECTORY: my-openproject-bucket
OPENPROJECT_FOG_CREDENTIALS_PROVIDER: AWS
OPENPROJECT_FOG_CREDENTIALS_AWS__ACCESS__KEY__ID: <key>
OPENPROJECT_FOG_CREDENTIALS_AWS__SECRET__ACCESS__KEY: <secret>
OPENPROJECT_FOG_CREDENTIALS_REGION: us-east-1Existing attachments do not migrate automatically. Run the copy task after switching:
docker compose exec web bundle exec rake attachments:copy_to[fog]11. Backups
You need three things: the PostgreSQL database, the assets directory, and .env.
sudo nano /usr/local/bin/openproject-backup.sh#!/usr/bin/env bash
set -euo pipefail
STACK=/opt/openproject
DEST=/var/backups/openproject
STAMP=$(date +%F-%H%M)
KEEP_DAYS=14
mkdir -p "$DEST"
cd "$STACK"
# Database
docker compose exec -T db pg_dump -U postgres -Fc openproject \
> "$DEST/openproject-db-$STAMP.dump"
# Assets and attachments
tar -czf "$DEST/openproject-assets-$STAMP.tar.gz" -C /var/openproject assets
# Configuration and secrets
cp "$STACK/.env" "$DEST/openproject-env-$STAMP"
if [ -f "$STACK/docker-compose.override.yml" ]; then
cp "$STACK/docker-compose.override.yml" "$DEST/openproject-override-$STAMP.yml"
fi
chmod 600 "$DEST"/openproject-env-*
find "$DEST" -type f -mtime +$KEEP_DAYS -deletesudo chmod +x /usr/local/bin/openproject-backup.sh
sudo /usr/local/bin/openproject-backup.shSchedule it:
sudo crontab -e15 2 * * * /usr/local/bin/openproject-backup.sh >> /var/log/openproject-backup.log 2>&1Ship the backups off the VPS. A backup sitting on the same disk as the database is not a backup. Use rclone, restic, or rsync to a second RamNode instance or object storage.
Restore
cd /opt/openproject
docker compose stop web worker cron seeder
docker compose exec -T db dropdb -U postgres --if-exists openproject
docker compose exec -T db createdb -U postgres openproject
docker compose exec -T db pg_restore -U postgres -d openproject --no-owner \
< /var/backups/openproject/openproject-db-2026-07-16-0215.dump
sudo tar -xzf /var/backups/openproject/openproject-assets-2026-07-16-0215.tar.gz \
-C /var/openproject
sudo chown -R 1000:1000 /var/openproject/assets
docker compose up -dRestore .env with the original SECRET_KEY_BASE or every session breaks.
Test the restore. On a fresh VPS. Before you need it.
12. Upgrades
OpenProject ships breaking changes between majors. Read the release notes before every major bump.
Patch and minor within the same major
cd /opt/openproject
sudo /usr/local/bin/openproject-backup.sh
docker compose pull --ignore-buildable
docker compose up -d --build
docker compose logs -f webMigrations run automatically on container start.
Major version bump
Do not skip majors. Go 15 to 16 to 17, not 15 to 17.
cd /opt/openproject
sudo /usr/local/bin/openproject-backup.sh
git fetch --depth=1 origin stable/17
git checkout stable/17
git reset --hard origin/stable/17Your .env and docker-compose.override.yml are gitignored and survive. Update TAG in .env to match:
sed -i 's/^TAG=.*/TAG=17/' .env
docker compose pull --ignore-buildable
docker compose up -d --build
docker compose logs -f webWatch for Migrating in the web container log. A long-running migration on a small VPS can take ten minutes. Do not interrupt it.
PostgreSQL major upgrades
The compose file pins a PostgreSQL image. Bumping it does not migrate the data directory. If the db container refuses to start after an image bump with a version mismatch error, dump, wipe the volume, and restore:
docker compose exec -T db pg_dumpall -U postgres > /tmp/all.sql
docker compose down
docker volume rm openproject_pgdata
# bump the image tag, then
docker compose up -d db
sleep 20
docker compose exec -T db psql -U postgres < /tmp/all.sql
docker compose up -d13. Operations
Rails console
cd /opt/openproject
docker compose exec web bundle exec rails consoleUseful one-liners:
# Reset a locked-out admin password
u = User.find_by(login: 'admin'); u.password = 'NewStrongPassword1!'; u.password_confirmation = 'NewStrongPassword1!'; u.save!
# Clear the Rails cache
Rails.cache.clear
# Count work packages by project
Project.all.map { |p| [p.identifier, p.work_packages.count] }Background jobs
docker compose logs -f worker
docker compose exec web bundle exec rails runner \
'puts GoodJob::Job.where(finished_at: nil).count'A growing queue with an idle worker means the worker container died. Check docker compose ps.
Log rotation
Docker's default json-file driver grows without bound. Cap it:
sudo nano /etc/docker/daemon.json{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}sudo systemctl restart docker
cd /opt/openproject && docker compose up -d14. Troubleshooting
ERR_SSL_PROTOCOL_ERROR in the browser.
OPENPROJECT_HTTPS=true is set but nothing terminates TLS, or Nginx is not passing X-Forwarded-Proto: https. Check the header is present in the server block.
Infinite redirect loop on /login.
Same root cause. OpenProject sees http in the forwarded proto and redirects to https, which arrives at Nginx and gets forwarded as http again. Fix the header.
Assets return 404, page loads unstyled.
OPDATA points at a directory the container cannot write to. Confirm /var/openproject/assets is owned by 1000:1000.
Work package attachments fail with 413.
Nginx client_max_body_size is too small. Raise it and reload.
Collaborative editing does not connect.
Websockets are not reaching hocuspocus. Confirm COLLABORATIVE_SERVER_URL uses wss:// and the public hostname, and that the Upgrade and Connection headers are set in Nginx.
Container exits with code 137.
OOM. Add or increase swap, lower WEB_CONCURRENCY, or move to a larger plan.
PostgreSQL connection pool timeouts under load.
RAILS_MAX_THREADS * WEB_CONCURRENCY exceeds the pool value in DATABASE_URL. Raise pool or lower threads.
Health check flapping.
docker compose exec web curl -sf http://localhost:8080/health_checks/defaultIf this fails inside the container, the app is genuinely unhealthy and the log will say why.
Everything is slow after a restart. memcached is cold and Rails is recompiling nothing but is fetching every setting from the database. Give it two minutes.
15. Is OpenProject the Right Fit?
OpenProject is the most feature-complete option in this category and the most demanding. It earns its footprint if you need Gantt with real scheduling, budgets and cost types, or BIM. It is overkill if you want a kanban board and a backlog.
If the resource profile does not fit the plan you want to run, look at Taiga for agile-first teams or Leantime for a lighter, strategy-oriented tool. Both run comfortably in half the memory.
Reference
- OpenProject installation and operations documentation: https://www.openproject.org/docs/installation-and-operations/
- Compose recipes: https://github.com/opf/openproject-docker-compose
- Configuration reference: https://www.openproject.org/docs/installation-and-operations/configuration/
