Project Management
    Docker Compose

    Deploy OpenProject on a VPS

    Deploy OpenProject Community Edition with Docker Compose on a RamNode KVM VPS — classic PM, agile boards, Gantt, time tracking, and a wiki behind Nginx TLS.

    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.

    UsersvCPURAMDiskNotes
    Evaluation / 1 to 524 GB40 GBWorks, but expect slow asset serving on first boot. Add swap.
    10 to 30 active48 GB80 GBRecommended baseline.
    50+ active, heavy attachments4 to 616 GB160 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.com at the VPS IPv4 address, and an AAAA record if you use IPv6
    • Ports 80 and 443 reachable

    Set the hostname and timezone first:

    shell
    sudo hostnamectl set-hostname projects
    sudo timedatectl set-timezone UTC

    Update and install the basics:

    shell
    sudo apt update && sudo apt -y upgrade
    sudo apt -y install curl ca-certificates git ufw nginx

    Add 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.

    shell
    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.conf

    Firewall

    shell
    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 enable

    Docker 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.

    shell
    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 docker

    Confirm:

    shell
    docker compose version

    4. 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.

    shell
    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/openproject

    Check what the branch actually is before you commit to it:

    shell
    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.

    shell
    sudo mkdir -p /var/openproject/assets
    sudo chown -R 1000:1000 /var/openproject/assets

    5. Configure the Environment

    shell
    cd /opt/openproject
    cp .env.example .env

    Generate the secrets before you edit:

    shell
    openssl rand -hex 64   # SECRET_KEY_BASE
    openssl rand -hex 32   # COLLABORATIVE_SERVER_SECRET
    openssl rand -base64 24 | tr -d '/+=' # database password

    Edit .env:

    shell
    nano .env

    Set at minimum:

    shell
    # 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=en

    Lock the file down. It holds your database password and Rails secret.

    shell
    chmod 600 .env

    On 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.

    shell
    cd /opt/openproject
    docker compose up -d --build --pull always

    You 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:

    shell
    docker compose pull --ignore-buildable

    First boot takes several minutes. The seeder container populates the database with default types, statuses, and workflows. Watch it:

    shell
    docker compose logs -f seeder
    docker compose logs -f web

    Wait for the web container to report healthy:

    shell
    docker compose ps

    The health check hits /health_checks/default on port 8080 inside the container. Once it is healthy:

    shell
    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:

    shell
    docker compose up -d seeder

    7. 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:

    shell
    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-email

    If port 80 is blocked upstream or you are behind Cloudflare, use DNS-01 instead:

    shell
    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-email

    Write the server block:

    shell
    sudo nano /etc/nginx/sites-available/openproject
    shell
    upstream 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:

    shell
    sudo nano /etc/nginx/conf.d/upgrade-map.conf
    shell
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    Enable and reload:

    shell
    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 nginx

    Certbot's systemd timer handles renewal. Add a deploy hook so Nginx picks up the new certificate:

    shell
    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-run

    Caddy 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.com if 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:

    shell
    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:

    shell
    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.com

    Recreate the app containers:

    shell
    docker compose up -d

    Send a test from Administration then Emails and notifications then Email notifications.

    If delivery hangs, the container usually cannot resolve DNS. Check:

    shell
    docker compose exec web getent hosts smtp.postmarkapp.com

    Leave 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.

    shell
    nano /opt/openproject/docker-compose.override.yml
    shell
    services:
      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=50

    Apply:

    shell
    cd /opt/openproject
    docker compose down && docker compose up -d

    Watch actual usage before you tighten limits further:

    shell
    docker stats --no-stream

    Rules of thumb:

    • WEB_CONCURRENCY multiplies memory. Each Puma worker is a full Rails process.
    • RAILS_MAX_THREADS times WEB_CONCURRENCY must stay below the pool=20 in DATABASE_URL, or you will see connection pool timeouts.
    • PostgreSQL shared_buffers at 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:

    shell
          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-1

    Existing attachments do not migrate automatically. Run the copy task after switching:

    shell
    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.

    shell
    sudo nano /usr/local/bin/openproject-backup.sh
    shell
    #!/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 -delete
    shell
    sudo chmod +x /usr/local/bin/openproject-backup.sh
    sudo /usr/local/bin/openproject-backup.sh

    Schedule it:

    shell
    sudo crontab -e
    shell
    15 2 * * * /usr/local/bin/openproject-backup.sh >> /var/log/openproject-backup.log 2>&1

    Ship 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

    shell
    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 -d

    Restore .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

    shell
    cd /opt/openproject
    sudo /usr/local/bin/openproject-backup.sh
    docker compose pull --ignore-buildable
    docker compose up -d --build
    docker compose logs -f web

    Migrations run automatically on container start.

    Major version bump

    Do not skip majors. Go 15 to 16 to 17, not 15 to 17.

    shell
    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/17

    Your .env and docker-compose.override.yml are gitignored and survive. Update TAG in .env to match:

    shell
    sed -i 's/^TAG=.*/TAG=17/' .env
    docker compose pull --ignore-buildable
    docker compose up -d --build
    docker compose logs -f web

    Watch 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:

    shell
    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 -d

    13. Operations

    Rails console

    shell
    cd /opt/openproject
    docker compose exec web bundle exec rails console

    Useful one-liners:

    shell
    # 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

    shell
    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:

    shell
    sudo nano /etc/docker/daemon.json
    shell
    {
      "log-driver": "json-file",
      "log-opts": {
        "max-size": "50m",
        "max-file": "3"
      }
    }
    shell
    sudo systemctl restart docker
    cd /opt/openproject && docker compose up -d

    14. 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.

    shell
    docker compose exec web curl -sf http://localhost:8080/health_checks/default

    If 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