SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host a file converter with HRConvert2

Run HRConvert2 on your own VPS so client files never touch a free converter site. Docker or Apache install, bubblewrap sandboxing, upload limits and cleanup.

Why self-host a file converter

A self-hosted file converter keeps the file on your own disk. That is the entire reason to run one. A free converter site takes the upload and gives you no way to know what happened to it afterwards, and when the file is a signed client contract or a scanned medical record, the upload itself is the incident. HRConvert2 is a drag and drop file conversion server written in PHP and licensed under the GPLv3. Version 3.7.4 was released on 18 August 2026 and the project claims 488 supported formats.

It has no database, no accounts and no cookies. A user is a temporary directory. Every conversion is a local command line tool doing the work: LibreOffice for documents, FFmpeg for audio and video, ImageMagick for images, Tesseract for optical character recognition (OCR), and a long tail of smaller tools behind the rest. HRConvert2 is the upload page, the pipeline and the cleanup around them.

It converts a file from one format to another. It is not a browser office suite, so if what you want is people editing documents in a tab, compare self-hosted OnlyOffice and Collabora instead. It is also not storage. Converted output is meant to be deleted, so if files need to live somewhere, that job belongs to a self-hosted file manager.

What it needs

Debian or Ubuntu, Apache 2.4, PHP 8 or later, and bubblewrap. Bubblewrap (bwrap) is the sandbox, and it is not optional: a server that cannot build a sandbox refuses the conversion instead of running without one. The upstream README says a Raspberry Pi Model B+ is enough, which is true of the PHP part. The converter binaries set your real hardware budget, and that is covered further down.

There are two ways in. The Docker image works tonight. The Apache and PHP install takes an evening and shows you exactly what is on the box.

Run it tonight with Docker

The image ships every converter binary, so it is large: about 3 GB as of August 2026. Check free disk before you pull.

Tags matter here. The newest tag published on Docker Hub as of 17 August 2026 is v3.7.2, while the newest GitHub release is v3.7.4. The latest tag moves under you, and this application is a large parser surface, so pin a version and upgrade on purpose.

docker pull zelon88/hrconvert2:v3.7.2
docker run -d --name hrconvert2 \
  -p 127.0.0.1:8080:80 \
  --security-opt seccomp=unconfined \
  zelon88/hrconvert2:v3.7.2
docker ps
curl -I http://127.0.0.1:8080/

A healthy container stays in Up state and the curl returns HTTP/1.1 200 OK. A container that keeps restarting has a startup problem, so read docker logs hrconvert2 before changing anything else.

Two flags carry weight. -p 127.0.0.1:8080:80 publishes the port on loopback only, so nothing reaches the converter until you put a proxy in front of it deliberately. The project's own example maps -p 8080:80 -p 8443:443, which listens on every interface, including the public one. --security-opt seccomp=unconfined is there because bubblewrap builds its sandbox using user namespace and mount system calls that Docker's default seccomp profile blocks. Without the flag, conversions fail and the application tells you why: A sandbox blocks the required syscalls unless it was started with the correct options.

That flag is a real trade. You relax the container's syscall filter so the application can build its own tighter sandbox inside it. The two settings that decide the behaviour are $RequireSandbox and $RequireSandboxOnDocker in Resources/config.php, which default to TRUE and FALSE. Because the Docker requirement is off by default, a container without the seccomp flag can convert with no sandbox at all. Once the flag is in place, set $RequireSandboxOnDocker = TRUE; and you get the refusal behaviour back inside the container.

If Docker is new on this machine, set the daemon up first. Running Docker on a VPS covers the install, the storage driver and the way Docker writes its own firewall rules.

Install it on Apache and PHP instead

The file Documentation/INSTALLATION_INSTRUCTIONS.txt in the repository is the authority, and it runs to nine steps. This is the shape of it. Start with the web server, the language and the sandbox:

sudo apt update
sudo apt install -y apache2 php libapache2-mod-php php-all-dev php8.3-zip php8.3-gd bubblewrap

The php8.3-* names match Ubuntu 24.04. Run php -v and use the prefix that matches your version, because those package names change with every PHP release and the wrong one gives you Unable to locate package.

Then the converters. This covers documents, images, audio, video and OCR, which is most of what people actually convert:

sudo apt install -y imagemagick ffmpeg libreoffice-common libreoffice-java-common \
  default-jre ghostscript poppler-utils libgxps-utils tesseract-ocr inkscape \
  xvfb clamav curl tar libxcb-cursor0

Archive formats, 3D models, ebooks and bootable ISO images need more packages than this, and some of them live in Ubuntu's multiverse component. Steps 3 and 5 of the official instructions carry the full list in order. Two dependencies are not apt packages at all: the repository ships Documentation/Build/ffmpeg-build.sh and Documentation/Build/build-imagemagick-v7.sh for people who need encoders or an ImageMagick 7 that Ubuntu does not package. Ebook support comes from calibre's own installer, which the instructions give as one line:

sudo -v && wget -nv -O- https://download.calibre-ebook.com/linux-installer.sh | sudo sh /dev/stdin

That is a vendor script piped into a shell as root. It is the upstream method, and it is optional: skip it and ebook conversion is the only thing you lose.

Next, PHP's limits. Conversions are slow and files are big, so the stock values are too small. The project sets these in php.ini:

max_execution_time = 1200
max_input_time = 90
memory_limit = 512M
post_max_size = 5000M
upload_max_filesize = 5000M
max_file_uploads = 100
display_errors = Off
zlib.output_compression = On

Those numbers assume a machine with room. Lower them before you go anywhere near a small VPS, because upload_max_filesize = 5000M with max_file_uploads = 100 describes a single request that can write far more than a 40 GB disk holds. Restart Apache and confirm what PHP actually loaded:

sudo service apache2 restart
php -i | grep -E "upload_max_filesize|post_max_size|memory_limit"

Now the working directory. $ConvertLoc in Resources/config.php names it, and the default is /DATA/HRConvert2. The web server user has to own it:

sudo mkdir -p /DATA/HRConvert2
sudo chmod -R 0755 /DATA/HRConvert2
sudo chown -R www-data:www-data /DATA/HRConvert2

Unpack the release under your Apache document root. The default layout puts it in a HRProprietary/HRConvert2 folder, and $InstLoc in Resources/config.php has to name wherever you actually put it. Then run the built in diagnostic, which is the fastest way to find a missing dependency before a user does:

sudo php /path/to/HRConvert2/convertCore.php -v

-v walks the whole install: core versions, dependency checks, sandbox status and language packs. File conversions are not supported from the command line, so this argument set is administrative only.

Why does every conversion fail on a fresh Ubuntu 24.04 install?

Because of the sandbox, and this is the single most common first day problem. Ubuntu 24.04 and Debian 12 restrict unprivileged user namespaces by default. Bubblewrap needs a user namespace to build its sandbox, so bwrap cannot start, and because the application refuses to convert without a sandbox, every single job fails.

Check it directly:

bwrap --ro-bind / / --dev /dev /bin/true && echo sandbox ok

A permission denied error means the namespace was blocked. The fix is an AppArmor profile for the bwrap binary. First list the ABI files and note the highest number present:

ls /etc/apparmor.d/abi/

Then write /etc/apparmor.d/bwrap, replacing 4.0 with that highest number:

abi <abi/4.0>,
include <tunables/global>

profile bwrap /usr/bin/bwrap flags=(unconfined) {
  userns,
  include if exists <local/bwrap>
}

Load it:

sudo apparmor_parser -r /etc/apparmor.d/bwrap

No output means the profile loaded. Run the bwrap check again and it should print sandbox ok. Conversions work from that point.

A public converter is a parser exposed to strangers

This is the section the rest of the post exists for. A file converter reachable from the internet accepts an arbitrary file from an anonymous person and hands it to LibreOffice, ImageMagick, FFmpeg or Ghostscript. Those are large C and C++ codebases with a long history of parser bugs. The person uploading picks the format, which means the person uploading picks which parser runs and which code path inside it.

HRConvert2's answer is to run every dependency inside a bubblewrap namespace. Each conversion sees two directories: the one holding its input, mounted read only, and the one receiving its output. The network is unshared, which, in the project's words, closes every URL handler in every dependency at once. That matters more than it sounds. ImageMagick and Ghostscript both accept references that fetch a URL, which is how a converter turns into a server side request forgery (SSRF) tool for reaching a cloud metadata endpoint from inside your network. With no network in the namespace, the fetch cannot happen.

The refusal is the other half: A server that cannot build a sandbox refuses the conversion rather than quietly running without one. A tool that fails closed is worth more than one that warns you in a log nobody reads. It is also why the AppArmor step above is not optional, and why $RequireSandboxOnDocker deserves a look before you expose the container.

Harden ImageMagick with policy.xml

ImageMagick's own policy file is a second layer under the sandbox, and it is worth setting. On Ubuntu 24.04 with ImageMagick 6 the file is /etc/ImageMagick-6/policy.xml. Print what is currently active:

identify -list policy

The project ships a policy at Documentation/Build/policy.xml and it is a good model. It denies the PS, PS2, PS3, EPS, XPS and MVG coders, and it denies the URL, HTTPS, HTTP and gs delegates, while allowing PDF:

<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="gs" />
<policy domain="coder" rights="read|write" pattern="PDF" />

The gs line is the important one. ImageMagick does not parse PostScript itself. It shells out to Ghostscript, and that delegate is where the well known ImageMagick remote code execution bugs live. Deny the delegate and ImageMagick will not hand an uploaded file to gs at all, whatever the file claims to be.

The same policy sets resource caps, which is how one crafted image is stopped from eating the machine:

<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<policy domain="resource" name="area" value="128MP"/>

A decompression bomb is a tiny file that declares enormous dimensions. The width, height and area limits refuse it before the allocation happens, so the process exits instead of the kernel killing something.

There is a trap in the other direction. Ubuntu's stock policy denies the PDF coder outright, so on an untouched system PDF work fails with attempt to perform an operation not allowed by the security policy 'PDF'. That string is the policy doing its job. Allowing the coder back is a decision you make deliberately, and you keep the gs delegate denied when you do.

What the dependency chain costs on a small VPS

Idle, none of this is expensive. Apache and PHP sit at a few tens of megabytes and the converter binaries are not running at all. The entire cost arrives at once, when a file lands.

A document conversion starts LibreOffice, which starts a Java runtime. An image conversion gives ImageMagick 256 MiB of memory plus a 512 MiB memory map under the policy above. A video conversion gives FFmpeg every core you have, because that is what FFmpeg does with a video. PHP's own memory_limit is 512M in the project's configuration. Those numbers stack during a single job, on top of the operating system and the web server.

So a 1 GB VPS swaps on the first real document and then thrashes. When memory runs out, the kernel out of memory killer ends whichever process has the largest resident size. Usually that is soffice.bin, and the user sees a conversion that failed with no useful message. Sometimes it is apache2, and the whole site goes down. Confirm it after the fact with dmesg -T | grep -i "killed process".

This is sizing guidance rather than a benchmark: 4 GB of RAM and two cores is comfortable for a small team, and 2 GB with a swap file works if the load is documents and images and you accept the wait. A swap file does not make a conversion faster. It makes a burst slow instead of fatal, which is the difference between a stalled page and an outage. Give the disk more room than feels necessary, because the 3 GB image, a large upload limit and the converted output together fill a disk long before you run out of anything else.

Conversions are bursty by nature. Two people uploading video at the same moment will use every core, and the next request waits behind them. There is no job queue in front of this, so the only control you have is limits.

Set the limits that stop one upload from filling the disk

Cut the PHP values first. Something like upload_max_filesize = 512M, post_max_size = 512M and max_file_uploads = 20 is a sane starting point for a shared 4 GB box. Keep in mind that max_execution_time = 1200 lets one PHP request run for twenty minutes, which a long video conversion genuinely needs, and which also means one slow upload holds a worker for twenty minutes.

Then enforce size and rate at the proxy, before the request ever reaches PHP:

limit_req_zone $binary_remote_addr zone=convert:10m rate=6r/m;

server {
    listen 443 ssl;
    server_name convert.example.com;

    client_max_body_size 512M;
    client_body_timeout 300s;

    location / {
        limit_req zone=convert burst=4 nodelay;
        proxy_pass http://127.0.0.1:8080;
        proxy_read_timeout 1200s;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

client_max_body_size must be at least as large as the biggest file you want converted, or nginx answers 413 Request Entity Too Large and PHP never sees the upload. proxy_read_timeout must exceed your longest conversion, or a job that is running perfectly well behind the proxy returns 504 Gateway Time-out to the browser. The rest of that server block, including TLS (transport layer security) termination, is covered in an nginx reverse proxy config explained line by line.

Delete the converted files

Every conversion leaves a copy of a sensitive file in a directory the web server can read. Cleanup is what separates a converter from an archive of everything anyone has ever converted on it.

$DeleteThreshold in Resources/config.php is the age in minutes at which a session expires, and it defaults to 60. Lower it to 15 when the content is sensitive. The sweep itself is a command line argument on the core:

sudo -u www-data php /path/to/HRConvert2/convertCore.php -c
sudo -u www-data php /path/to/HRConvert2/convertCore.php -c=15

-c clears expired sessions from both data locations using the configured threshold. -c=15 uses fifteen minutes for that run only. -c=now deletes every session regardless of age, including the one a user is converting at that moment, so keep it for maintenance. The same arguments work inside the container through docker exec.

Put the sweep on a timer so that cleanup never depends on someone loading a page. A line in /etc/cron.d/hrconvert2 is enough:

*/10 * * * * www-data php /path/to/HRConvert2/convertCore.php -c

Check it a few minutes later with ls /DATA/HRConvert2 and watch old session directories disappear. Because the web server user owns that directory, that account is exactly what an exploited parser would get, so it should own nothing else worth having. Least privilege user accounts on a VPS is the general pattern, and it applies here more than usual.

Put it behind authentication unless public is the goal

The default install has no accounts, by design. Anyone who can reach the page can upload a file and run your converter binaries, and rate limits only slow that down. So decide which situation you are in.

If it is for you and a few colleagues, do not expose it at all. Bind the container to loopback as shown above and reach it over a private network or an SSH tunnel. Nothing on the public internet can then send it a file, which removes the entire attack surface rather than filtering it.

If it has to be reachable from a browser, put authentication in front of the proxy. Basic auth is two commands and it keeps the upload form away from strangers:

sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd alice
location / {
    auth_basic "Converter";
    auth_basic_user_file /etc/nginx/.htpasswd;
    proxy_pass http://127.0.0.1:8080;
}

Reload nginx and load the page. A prompt means it is working, and no prompt means the location block you edited is not the one handling the request. For real accounts rather than a shared password, terminate at a single sign-on provider: a self-hosted Authentik SSO server gives you forward authentication in front of an application that has no login of its own.

If a genuinely public converter is the goal, then accept what that means and plan for it. Assume the sandbox will be probed. Keep the image tag pinned, keep the ImageMagick policy tight, keep the upload limits small, and run it on a VPS that holds nothing else you care about.

Failure modes and the strings you will see

Every conversion fails immediately. The sandbox cannot be built. On a normal install that is the AppArmor profile. In Docker it is the missing --security-opt seccomp=unconfined. The application names it: A sandbox blocks the required syscalls unless it was started with the correct options. and points at See --Require Sandbox-- & --Require Sandbox On Docker-- in config.php.

Only image conversions fail. Bubblewrap is missing or non functional, so this image conversion cannot be isolated! means bwrap is absent or not reachable on the path the web server user has.

One format fails and the rest work. A missing binary, reported plainly: ImageMagick may not be installed, or may not be reachable on the system path used by the web server user. The same message exists for FFmpeg and for LibreOffice. Run convertCore.php -v to see what the install can find, and remember that the Apache worker's PATH is not your login shell's PATH.

PDF work fails with a policy error. attempt to perform an operation not allowed by the security policy 'PDF' comes from ImageMagick's policy.xml, not from HRConvert2.

Large uploads return 413. nginx client_max_body_size is smaller than the file. There are three limits in the chain, one in nginx and two in PHP, and the smallest one wins.

Conversions stop and nothing obvious changed. The device where data is stored has an insufficient amount of storage space available. Check free space and check that the cleanup sweep is actually running.

Cleanup complains in the log. Could not clean the temporary location! and Could not clean the convert location! are ownership problems. The web server user must own the directory named by $ConvertLoc.

FAQ

Is it safe to expose a self-hosted file converter to the internet?

It is safe enough only if you treat it as a parser exposed to strangers. Every upload is handed to LibreOffice, ImageMagick, FFmpeg or Ghostscript, and the person uploading chooses which one. HRConvert2 runs those tools inside a bubblewrap namespace with no network and a read only input directory, and it refuses any conversion it cannot sandbox, which is a strong default. It is still better to require authentication, keep upload limits small, and run it on a VPS that holds nothing else of value.

Why does every conversion fail on a fresh Ubuntu 24.04 install?

Ubuntu 24.04 and Debian 12 restrict unprivileged user namespaces, and bubblewrap needs one to build its sandbox. Because the application refuses to convert without a sandbox, every job fails rather than some of them. Write an AppArmor profile for /usr/bin/bwrap with flags=(unconfined), load it with sudo apparmor_parser -r /etc/apparmor.d/bwrap, then confirm with bwrap --ro-bind / / --dev /dev /bin/true.

Why do conversions fail in Docker but work on a normal install?

Docker's default seccomp profile blocks the system calls bubblewrap uses, so the sandbox cannot be created inside the container. Start it with --security-opt seccomp=unconfined, which is what the project's own run command does. Note that $RequireSandboxOnDocker is FALSE by default, so an unflagged container may convert with no sandbox at all. Set it to TRUE once the seccomp flag is in place.

How much RAM does a file conversion server need?

Idle it is small, but a live conversion is not. LibreOffice starts a Java runtime, ImageMagick takes 256 MiB of memory and a 512 MiB map under the shipped policy, and PHP's own limit is 512M. On a 1 GB VPS that combination swaps and the out of memory killer ends soffice.bin or apache2. Plan on 4 GB and two cores for a small team, and check dmesg -T | grep -i "killed process" whenever a conversion dies without a message.

Where do converted files go, and when are they deleted?

They go to the working directory named by $ConvertLoc in Resources/config.php, which defaults to /DATA/HRConvert2. $DeleteThreshold sets the age in minutes at which a session expires, and it defaults to 60. The sweep runs from the command line: php convertCore.php -c clears expired sessions, and -c=now clears every session immediately, including active ones. Put -c on a cron entry or a systemd timer so that deletion does not depend on anyone visiting the site.