Self-Hosting Plane with Hetzner Object Storage: A Complete Setup Guide

Self-Hosting Plane with Hetzner Object Storage: A Complete Setup Guide
Hetzner Object Storage and Plane PM

If you're looking for a solid, open-source project management tool that you can self-host — Plane is one of the best options out there. It's clean, fast, and does most of what you'd expect from tools like Jira or Linear, without the subscription fatigue.

I recently deployed Plane v1.2.3 for our company using Docker Compose, and decided to use Hetzner Object Storage as the S3-compatible backend instead of the default MinIO instance that ships with Plane. This post walks through the entire setup — including the gotchas that cost me a few hours of debugging.

Why Hetzner Object Storage?

Plane uses an S3-compatible object store for file uploads — project cover images, attachments, avatars, and so on. The default setup bundles a MinIO container, which works fine for testing but adds overhead in production. Hetzner's Object Storage is cheap, S3-compatible, and already part of our infrastructure, so it made sense to consolidate.

Prerequisites

Before you start, make sure you have:

  • A server (I used a Hetzner cloud instance) with Docker and Docker Compose installed
  • A domain pointed to your server's IP (I used projects.mentormerlin.org)
  • A Hetzner Object Storage bucket created (mine was mentormerlininternal in the fsn1 region)
  • Your Hetzner Object Storage access key and secret key

Step 1: Configure the Environment File

Plane ships with a plane.env file that controls everything. Here are the critical changes you need to make.

Set your domain and SSL

APP_DOMAIN=projects.yourdomain.com

For SSL, Plane's proxy uses Caddy under the hood. The CERT_EMAIL variable has a specific format that Caddy expects — and this one tripped me up:

# WRONG - will crash the proxy container
[email protected]

# CORRECT - Caddy needs the 'email' keyword prefix
CERT_EMAIL=email [email protected]

Also set:

CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
SITE_ADDRESS=projects.yourdomain.com

And update URLs to HTTPS:

WEB_URL=https://${APP_DOMAIN}
CORS_ALLOWED_ORIGINS=https://${APP_DOMAIN}

Point storage to Hetzner

USE_MINIO=0
AWS_REGION=fsn1
AWS_ACCESS_KEY_ID=your-hetzner-access-key
AWS_SECRET_ACCESS_KEY=your-hetzner-secret-key
AWS_S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com
AWS_S3_BUCKET_NAME=mentormerlininternal
MINIO_ENDPOINT_SSL=1

The important bits:

  • USE_MINIO=0 tells Plane to skip the built-in MinIO and use an external S3-compatible store.
  • AWS_S3_ENDPOINT_URL should be the base Hetzner endpoint for your region — not your bucket-specific URL.
  • MINIO_ENDPOINT_SSL=1 because Hetzner serves over HTTPS.

Generate proper secrets

Don't go to production with the default keys. Generate new ones:

openssl rand -hex 32

Use the output for both SECRET_KEY and LIVE_SERVER_SECRET_KEY in your env file.

Step 2: Update docker-compose.yaml

Since we're using Hetzner instead of MinIO, comment out or remove the plane-minio service entirely:

  # COMMENTED OUT - Using Hetzner Object Storage instead
  # plane-minio:
  #   image: minio/minio:latest
  #   command: server /export --console-address ":9090"
  #   ...

Everything else in the compose file stays the same.

Step 3: Configure the Hetzner Bucket

This is the part that isn't documented anywhere and will silently break your setup. Plane will successfully upload files to your bucket, but then fail to read them back — you'll see errors like "Failed to upload cover image" even though the files are sitting right there in your bucket.

You need to do two things: set a public read policy and configure CORS.

Install and configure s3cmd

apt install s3cmd -y
s3cmd --configure

During configuration:

  • Access Key / Secret Key: Your Hetzner credentials
  • S3 Endpoint: fsn1.your-objectstorage.com
  • DNS-style bucket+hostname: %(bucket)s.fsn1.your-objectstorage.com
  • Use HTTPS: Yes

Set the bucket to public read

First, make existing objects readable:

s3cmd setacl s3://mentormerlininternal --acl-public --recursive

Then create a bucket-policy.json so that future uploads are also publicly readable:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicRead",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::mentormerlininternal/*"
    }
  ]
}

Apply it:

s3cmd setpolicy bucket-policy.json s3://mentormerlininternal

Set CORS policy

Create a cors.xml file:

<CORSConfiguration>
  <CORSRule>
    <AllowedOrigin>https://projects.yourdomain.com</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedMethod>DELETE</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
  </CORSRule>
</CORSConfiguration>

Apply it:

s3cmd setcors cors.xml s3://mentormerlininternal

Verify it's working

s3cmd info s3://mentormerlininternal

You can also try hitting one of the uploaded files directly in your browser. You should get the file content instead of a 403.

Step 4: Deploy

Make sure your env file is named .env and sits in the same directory as your docker-compose.yaml. Then:

docker compose up -d

Watch the logs to make sure everything comes up clean:

docker compose logs -f proxy
docker compose logs -f api

The proxy container should show Caddy obtaining your SSL certificate automatically. Give it a minute, then hit your domain in the browser.

Recap of Gotchas

Here's a quick summary of the things that bit me so you can avoid them:

  1. CERT_EMAIL needs the email prefix — Caddy's Caddyfile syntax requires email [email protected], not just the bare address. Without it, the proxy container crashes in a restart loop with "unrecognized global option."
  2. The env file must be named .env — If you're using a different filename, docker-compose won't pick it up unless you explicitly pass --env-file.
  3. Uploads succeed but reads fail — The bucket needs a public read policy AND a CORS policy. Without these, Plane writes files just fine but the browser can't load them back, resulting in misleading "Failed to upload" errors.
  4. USE_MINIO=0 and MINIO_ENDPOINT_SSL=1 — Both need to be set when using an external HTTPS-based S3 store.
  5. Use HTTPS in WEB_URL and CORS_ALLOWED_ORIGINS — If you have SSL enabled but these still say http://, you'll get mixed content issues.

Final Thoughts

Plane is a genuinely good project management tool for teams that want to self-host. Pairing it with Hetzner Object Storage instead of the bundled MinIO gives you a cleaner, more production-ready setup with less resource overhead. The initial configuration has some rough edges, but once it's running, it's solid.

If you run into issues or have questions, drop a comment below or reach out — happy to help.