# Can I self-host SurveyMonkey?

**YES** — it's called LimeSurvey. ONE EVENING setup · ~1.7 hours to running · 1 GB RAM minimum · $39/mo you stop paying ($468/yr on the Advantage Annual plan).

LimeSurvey authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/surveymonkey/

## Install prompt (Claude Code)

````text
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 LimeSurvey 7.0.7 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until
they answer. `<DOMAIN>` becomes `HOST_INFO`, the address inside every survey link, and its A
record must already point here. `<ADMIN_EMAIL>` goes on the administrator account the installer
creates, stored rather than mailed to, because this install configures no mail.

LimeSurvey and its database need 1024 MB of RAM available and 5 GB free on /srv. Both images
publish amd64 and arm64.

```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 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name nobody can resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/limesurvey /srv/limesurvey/backups
sudo install -d -m 700 /srv/limesurvey/mariadb
ls -la /srv/limesurvey
```

Assert: `backups` owned by the login user, `mariadb` at mode `700` owned by root. Leave that one
alone; the MariaDB image chowns its own data directory and refuses one somebody claimed first.
Uploads get no directory here: step 4 keeps those in a named volume.

## 3. Secrets

Five secrets, all generated here: the database password, the MariaDB root password, the
administrator's password, and the two encryption values LimeSurvey uses for participant records.
Print none of them, and keep them out of your summary and every log line.

```bash
umask 077
cat > /srv/limesurvey/.env <<EOF
HOST_INFO=https://<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
ENCRYPT_NONCE=$(openssl rand -hex 24)
ENCRYPT_SECRET_BOX_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/limesurvey/.env
umask 022
ls -l /srv/limesurvey/.env
```

Assert: mode `-rw-------` and the login user's name twice. Those 24 and 32 hex bytes are the
nonce and secret-box key lengths LimeSurvey's own generator produces. Tell the user, without
printing anything, that this file is now the most valuable object on the box: change those two
values and encrypted participant records stop decrypting for good.

## 4. compose.yml

```bash
cat > /srv/limesurvey/compose.yml <<'EOF'
# LimeSurvey · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   requirements ..... https://www.limesurvey.org/manual/Installation_-_LimeSurvey_CE
#   config reference . https://www.limesurvey.org/manual/Optional_settings
#   image README ..... https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md
#   image entrypoint . https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/7.0/apache/entrypoint.sh
#
# The LimeSurvey project publishes no Docker image. martialblog/limesurvey is a
# community image, MIT, maintained outside that project; its Dockerfile fetches
# the official LimeSurvey 7.0.7+260729 tarball and checks its sha256.
#
# Two services: Apache with PHP, and the MariaDB it keeps surveys and responses
# in. Every ${...} comes from /srv/limesurvey/.env, mode 600. `upload` is a named
# volume because the image ships that directory's base content, which a bind
# mount would hide. Digests read 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: limesurvey-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: limesurvey
      MARIADB_USER: limesurvey
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/limesurvey/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: martialblog/limesurvey:7.0.7-260729-apache@sha256:556d09839640f4702ee5ef6618a426c68f0688ded967b2805a0bd903a241f051
    container_name: limesurvey-app
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: limesurvey
      DB_USERNAME: limesurvey
      DB_PASSWORD: ${DB_PASSWORD}
      # Upstream's default engine for the wide table each survey gets. InnoDB
      # caps a row near 8 KB, which a long questionnaire goes past.
      DB_MYSQL_ENGINE: MyISAM
      # The entrypoint exits without ADMIN_PASSWORD; these four seed the
      # LimeSurvey console installer once, on first boot.
      ADMIN_USER: admin
      ADMIN_NAME: Site administrator
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      # Caddy terminates TLS, so LimeSurvey is told the scheme and host it
      # should build absolute links from.
      HOST_INFO: ${HOST_INFO}
      # Written to application/config/security.php on every start. Change
      # either one and encrypted participant data stops decrypting.
      ENCRYPT_NONCE: ${ENCRYPT_NONCE}
      ENCRYPT_SECRET_BOX_KEY: ${ENCRYPT_SECRET_BOX_KEY}
    volumes:
      - limesurvey-upload:/var/www/html/upload
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/index.php/admin/authentication/sa/login || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8130.
      # The container listens on 8080 and runs as www-data, not root.
      - "127.0.0.1:8130:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  limesurvey-upload:
EOF
cd /srv/limesurvey && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The image's entrypoint exits with an error if `DB_PASSWORD` or
`ADMIN_PASSWORD` is missing, so this install has no default account and no blank password.

## 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 takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-limesurvey
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LimeSurvey · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also HOST_INFO in .env, and it is the
# address inside every survey link you hand out.

<DOMAIN> {
	encode zstd gzip

	# LimeSurvey sets its own framing and content-security headers on the
	# admin side; these are the rest. The referrer is trimmed because a
	# shared survey link would otherwise carry its token onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8130 is the loopback port compose publishes on this host, not a
	# container port and not open in the firewall. Caddy passes the Host
	# header through, which is what the image README asks of a proxy in
	# front of LimeSurvey, and the image reads a response's client address
	# from X-Real-IP through Apache's mod_remoteip.
	reverse_proxy 127.0.0.1:8130 {
		header_up X-Real-IP {remote_host}
	}
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-limesurvey, reload,
and report the objection. Caddy gets the certificate on the first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8130 is bound to 127.0.0.1 and 3306 is never published, so neither has a host port.
Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, nothing else.

## 7. Start and verify

On the first start the entrypoint waits for MariaDB, writes LimeSurvey's config, then runs the
console installer. Read step 10 before interpreting that log.

```bash
cd /srv/limesurvey
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php/admin/authentication/sa/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/index.php/admin/authentication/sa/login | grep -c 'x-test id="action::login"'
curl -sS https://<DOMAIN>/index.php/installer | grep -c 'Installation has been done already'
docker compose exec -T app grep -c "'hostInfo' => 'https://<DOMAIN>'" application/config/config.php
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from lime_users" "$MARIADB_DATABASE"'
```

Assert all five, printing what you received for each. The loop ends on `200`. The second prints
`1`: that marker is the tag LimeSurvey's own test suite looks for to confirm the login page
rendered, so PHP reached the database. The third prints `1`, the security assert here, because a
config file exists and the browser installer now refuses whoever finds that URL. The fourth
prints `1`, so absolute links carry https and the right hostname. The fifth prints `1`, one
administrator, made by the console installer rather than a form on a public address. If any of
the five misses, stop, run `docker compose logs --tail 60 app` and
`docker compose logs --tail 20 db`, and name the likely step: a database that never reports
healthy is step 2, a lasting `502` is step 5. A running container is not success.

The first screen at https://<DOMAIN>/index.php/admin shows the heading `Administration` above
the words `Log in`, with a username and a password field.

STOP: tell the user to read their administrator password with
`sudo grep ADMIN_PASSWORD /srv/limesurvey/.env`, put it in their password manager, sign in at
https://<DOMAIN>/index.php/admin as the user `admin`, and wait. Do not continue until they
confirm they are on the dashboard. Editing that value in .env afterwards changes nothing: it
seeds the account once, and the password then lives in the database.

## 8. First backup and restore

Three artifacts: a dump holding every survey, question and response, an archive of themes,
plugins and participant uploads, and the config archive that rebuilds the service around them
and carries the encryption keys the other two need.

```bash
cd /srv/limesurvey
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/limesurvey/backups/limesurvey-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - upload > /srv/limesurvey/backups/limesurvey-upload-$(date +%F).tar.gz
sudo tar -czf /srv/limesurvey/backups/limesurvey-config-$(date +%F).tar.gz -C /srv/limesurvey compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/limesurvey/backups/
```

Assert: all three exist, all three are non-empty, all three sizes printed. The dump read-locks
each table as it reads it, because the survey tables are MyISAM and there is no transaction to
snapshot. On a fresh install that is under a second.

A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:

```bash
mkdir -p ~/backups/limesurvey
scp vps:/srv/limesurvey/backups/* ~/backups/limesurvey/
```

To restore: `docker compose down`, `sudo rm -rf /srv/limesurvey/mariadb`, recreate it as in
step 2, untar the config archive into /srv/limesurvey so `.env` is back first,
`docker compose up -d db`, wait about 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`, then the uploads with
`docker compose exec -T app tar -C /var/www/html -xzf - < backups/limesurvey-upload-<date>.tar.gz`.
Tell the user why `.env` comes back first: MariaDB reads its password from it the moment it
initialises an empty directory, and its encryption values are the only way restored participant
data decrypts.

## 9. Updating later

Application versions are listed at https://github.com/LimeSurvey/LimeSurvey/tags and the image
tags carrying them at https://github.com/martialblog/docker-limesurvey/tags. Take all three
backups first, then edit the app image line in /srv/limesurvey/compose.yml to the new tag and
digest:

```bash
cd /srv/limesurvey
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

The entrypoint runs `console.php updatedb` on every start, so a version bump migrates the schema
on its own. Watch that log until it settles, then re-run step 7's five checks.

## 10. What will probably go wrong

The first `docker compose logs app` prints a full PHP stack trace and reads like a failed
install. Mine did, and I spent ten minutes on it before noticing the line above telling me to
never mind the trace. That is the entrypoint asking an empty database whether it has been
migrated; the only way to ask is to try, and the try throws. The line after
it reads `Running console.php install`, which is the install working. If step 7 does fail, look
for a line about the connection to `db` instead.

## 11. Out of scope

- Do not configure SMTP. Anonymous link surveys are the whole product without it, and mail is
  what invitations and reminders need: a second install to do properly.
- Do not enable the RemoteControl API. It is off by default, and turning it on adds a credential
  nobody here is holding.
- Do not use ComfortUpdate or the in-app updater. This container is pinned by digest, and an
  updater rewriting files inside it puts the running code out of step with the tag.
- Do not switch the database to PostgreSQL. The image supports it and MariaDB is the choice here.
````

## Chat fallback

````text
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 LimeSurvey 7.0.7 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, and `<ADMIN_EMAIL>` with the address for your administrator account.

Read this before step 1. `<DOMAIN>` becomes `HOST_INFO`, the address LimeSurvey puts inside
every survey link and every participant invitation. Moving it later means every link you have
already sent stops working, so pick the hostname you intend to keep.

One more thing worth knowing up front: LimeSurvey publishes no Docker image of its own. The
image below, `martialblog/limesurvey`, is a community image under the MIT licence, maintained
outside the LimeSurvey project. Its Dockerfile downloads the official LimeSurvey 7.0.7+260729
release tarball from LimeSurvey's own repository and checks its sha256 before unpacking it, so
the application is upstream's; the packaging around it is not.

## 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 `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 1024 MB of RAM is
the other common stop: PHP and MariaDB in one box is the floor here, and the OOM killer arriving
during a survey activation looks like a LimeSurvey bug rather than a hosting decision.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/limesurvey /srv/limesurvey/backups
sudo install -d -m 700 /srv/limesurvey/mariadb
ls -la /srv/limesurvey
```

You should see: `backups` owned by you, and `mariadb` at mode `drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. There is deliberately no directory here for LimeSurvey's uploads: step 4 keeps
those in a named volume, because the image ships that directory's base content and an empty host
folder mounted over it would hide the themes and plugins the release comes with.

## 3. Secrets

Five secrets: the database password, the MariaDB root password, the administrator's password,
and the two data-encryption values LimeSurvey uses for participant records. All five are
generated here, on the server, and all five go straight into a file only you can read.

```bash
umask 077
cat > /srv/limesurvey/.env <<EOF
HOST_INFO=https://<DOMAIN>
ADMIN_EMAIL=<ADMIN_EMAIL>
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
ENCRYPT_NONCE=$(openssl rand -hex 24)
ENCRYPT_SECRET_BOX_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /srv/limesurvey/.env
umask 022
ls -l /srv/limesurvey/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` on the first two lines with your real values before you paste.

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/limesurvey/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all five values, which is fine before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, and the two encryption values are how LimeSurvey reads
back anything it has already encrypted.

Do not paste that file, any of those five values, or any command output containing them into
this chat window. Those 24 and 32 hex bytes are the nonce and secret-box key lengths
LimeSurvey's own generator produces; keep them, because changing either one later means
encrypted participant records stop decrypting, permanently and with no recovery path.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/limesurvey/compose.yml <<'EOF'
# LimeSurvey · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   requirements ..... https://www.limesurvey.org/manual/Installation_-_LimeSurvey_CE
#   config reference . https://www.limesurvey.org/manual/Optional_settings
#   image README ..... https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md
#   image entrypoint . https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/7.0/apache/entrypoint.sh
#
# The LimeSurvey project publishes no Docker image. martialblog/limesurvey is a
# community image, MIT, maintained outside that project; its Dockerfile fetches
# the official LimeSurvey 7.0.7+260729 tarball and checks its sha256.
#
# Two services: Apache with PHP, and the MariaDB it keeps surveys and responses
# in. Every ${...} comes from /srv/limesurvey/.env, mode 600. `upload` is a named
# volume because the image ships that directory's base content, which a bind
# mount would hide. Digests read 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: limesurvey-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: limesurvey
      MARIADB_USER: limesurvey
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/limesurvey/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: martialblog/limesurvey:7.0.7-260729-apache@sha256:556d09839640f4702ee5ef6618a426c68f0688ded967b2805a0bd903a241f051
    container_name: limesurvey-app
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: limesurvey
      DB_USERNAME: limesurvey
      DB_PASSWORD: ${DB_PASSWORD}
      # Upstream's default engine for the wide table each survey gets. InnoDB
      # caps a row near 8 KB, which a long questionnaire goes past.
      DB_MYSQL_ENGINE: MyISAM
      # The entrypoint exits without ADMIN_PASSWORD; these four seed the
      # LimeSurvey console installer once, on first boot.
      ADMIN_USER: admin
      ADMIN_NAME: Site administrator
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      # Caddy terminates TLS, so LimeSurvey is told the scheme and host it
      # should build absolute links from.
      HOST_INFO: ${HOST_INFO}
      # Written to application/config/security.php on every start. Change
      # either one and encrypted participant data stops decrypting.
      ENCRYPT_NONCE: ${ENCRYPT_NONCE}
      ENCRYPT_SECRET_BOX_KEY: ${ENCRYPT_SECRET_BOX_KEY}
    volumes:
      - limesurvey-upload:/var/www/html/upload
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/index.php/admin/authentication/sa/login || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8130.
      # The container listens on 8080 and runs as www-data, not root.
      - "127.0.0.1:8130:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  limesurvey-upload:
EOF
cd /srv/limesurvey && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/limesurvey/.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/limesurvey/compose.yml` and paste again in one go. A warning that a variable is not
set means one of the five names in .env does not match the ones above, and the image's
entrypoint exits rather than starting with a blank password, which is the behaviour you want.

## 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-limesurvey
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LimeSurvey · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also HOST_INFO in .env, and it is the
# address inside every survey link you hand out.

<DOMAIN> {
	encode zstd gzip

	# LimeSurvey sets its own framing and content-security headers on the
	# admin side; these are the rest. The referrer is trimmed because a
	# shared survey link would otherwise carry its token onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8130 is the loopback port compose publishes on this host, not a
	# container port and not open in the firewall. Caddy passes the Host
	# header through, which is what the image README asks of a proxy in
	# front of LimeSurvey, and the image reads a response's client address
	# from X-Real-IP through Apache's mod_remoteip.
	reverse_proxy 127.0.0.1:8130 {
		header_up X-Real-IP {remote_host}
	}
}
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-limesurvey /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why
`HOST_INFO` in .env says `https://`: without it LimeSurvey would build `http://` links for a
service only reachable over https.

## 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 `8130` or `3306`.

If you do not: delete anything for `8130` or `3306` with `sudo ufw delete allow 8130`. 8130 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and answer 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.

## 7. Start and verify

On the first start the entrypoint waits for MariaDB, writes LimeSurvey's config, then runs the
console installer. Read step 10 before you interpret the log.

```bash
cd /srv/limesurvey
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php/admin/authentication/sa/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/index.php/admin/authentication/sa/login | grep -c 'x-test id="action::login"'
curl -sS https://<DOMAIN>/index.php/installer | grep -c 'Installation has been done already'
docker compose exec -T app grep -c "'hostInfo' => 'https://<DOMAIN>'" application/config/config.php
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from lime_users" "$MARIADB_DATABASE"'
```

You should see, in order: the loop reaching `200`, then `1`, then `1`, then `1`, then `1`.

If you do not: the third line is the one worth understanding. It is the security check. Once
`application/config/config.php` exists, LimeSurvey's installer refuses to run and answers
`Installation has been done already. Installer disabled.`, so a `0` there means a setup form is
sitting open on a public hostname and you stop everything until it is not. A `0` on the second
line with a `200` from the loop usually means Caddy reached something other than LimeSurvey. If
the loop never gets to `200`, run `docker compose logs --tail 20 db` first, because a database
that never reports healthy is step 2 done wrong, and `docker compose logs --tail 60 app` second.
The last number is the count of administrator accounts, and `1` is the whole point: the account
was made by a console command with a generated password, not by anyone who found a setup wizard.

The first screen at https://<DOMAIN>/index.php/admin shows the heading `Administration` above
the words `Log in`, with a username field and a password field.

Now sign in. Read your password with `sudo grep ADMIN_PASSWORD /srv/limesurvey/.env`, put it in
your password manager, and log in as the user `admin`. Do not paste the password into this chat
window. Editing that line in .env afterwards changes nothing: it seeded the account once, and
the password now lives in the database and moves in the profile screen.

## 8. First backup and restore

Three artifacts. The dump holds every survey, question and response. The upload archive holds
themes, plugins and participant file uploads. The config archive rebuilds the service around
them and carries the encryption keys, without which the other two are half-readable.

```bash
cd /srv/limesurvey
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/limesurvey/backups/limesurvey-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - upload > /srv/limesurvey/backups/limesurvey-upload-$(date +%F).tar.gz
sudo tar -czf /srv/limesurvey/backups/limesurvey-config-$(date +%F).tar.gz -C /srv/limesurvey compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/limesurvey/backups/
```

You should see: three files, the dump and the config archive a few kilobytes on a fresh install
and the upload archive a little larger.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error. The
dump takes a read lock on each table as it reads it, because the survey tables are MyISAM and
there is no transaction to snapshot instead; on a fresh install that is under a second.

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/limesurvey
scp vps:/srv/limesurvey/backups/* ~/backups/limesurvey/
```

You should see: three files copied, and all three listed by `ls -lh ~/backups/limesurvey/`.

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/limesurvey
docker compose down
sudo rm -rf /srv/limesurvey/mariadb
sudo install -d -m 700 /srv/limesurvey/mariadb
docker compose up -d db
sleep 30
gunzip -c /srv/limesurvey/backups/limesurvey-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/index.php/admin/authentication/sa/login | grep -c 'x-test id="action::login"'
```

You should see: some `CREATE TABLE` chatter from the client, then `1` from the last command,
which means the login page came back from a database that was deleted and rebuilt.

If you do not: `Access denied for user` means .env was not in place before the database
container initialised its empty directory, so it created itself with a different password. That
is also why the restore order matters: untar the config archive first, always. Understand what
the encryption keys do while you are here. They live only in .env, they are written into the
container at every start, and a restore without them gives you rows you cannot read.

## 9. Updating later

Application versions are listed at https://github.com/LimeSurvey/LimeSurvey/tags and the image
tags that carry them at https://github.com/martialblog/docker-limesurvey/tags. Take all three
backup artifacts first, then edit the app `image:` line in /srv/limesurvey/compose.yml to the new
tag and its digest.

```bash
cd /srv/limesurvey
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

You should see: the config already provisioned, a database migration pass, then Apache starting,
and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. The entrypoint
runs `console.php updatedb` on every start, so the schema migrates itself, which also means a
half-finished migration is a real state to land in. Re-run all five checks from step 7 before
you call the update done.

## 10. What will probably go wrong

The first `docker compose logs app` prints a full PHP stack trace and reads like a failed
install. Mine did, and I spent ten minutes on it before noticing the line above telling me to
never mind the trace. That is the entrypoint asking an empty database whether it has been
migrated; the only way to ask is to try, and the try throws. The line after it reads
`Running console.php install`, which is the install working. If step 7 does fail, look for a
line about the connection to `db` instead.

## 11. Out of scope

- Do not configure SMTP. Anonymous link surveys are the whole product without it, and mail is
  what invitations and reminders need: a second install to do properly.
- Do not enable the RemoteControl API. It is off by default, and turning it on adds a credential
  nobody here is holding.
- Do not use ComfortUpdate or the in-app updater. This container is pinned by digest, and an
  updater rewriting files inside it puts the running code out of step with the tag.
- Do not switch the database to PostgreSQL. The image supports it and MariaDB is the choice here.
````

## Local install prompt (your own computer, no server)

````text
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 LimeSurvey 7.0.7, with the MariaDB it stores surveys and responses in, under
~/selfhost/limesurvey, answering at http://localhost:8130.

## 1. Preflight

Say this before step 2 runs; it decides whether the user wants this. Every survey link this
makes begins with http://localhost:8130, which means "this computer" wherever it is
read, so one sent to a colleague or opened on the user's own phone resolves to nothing. They get
LimeSurvey's question logic and a private place to build questionnaires, not a survey anyone
else can answer.

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 prints next, for step 2. LimeSurvey plus MariaDB needs 1024 MB of RAM available
and 5 GB free on the home disk, and both images publish amd64 and arm64. If either is under its
floor, 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/limesurvey/backups
ls -la ~/selfhost/limesurvey
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: surveys are rows
in MariaDB, uploads live in a directory the image fills, and step 5 keeps both in volumes.

## 4. Secrets

Five secrets, all generated here: the database and MariaDB root passwords, the administrator's
password, and the two encryption values LimeSurvey uses for participant records. Print none, and
keep them out of your summary and every log.

```bash
umask 077
cat > ~/selfhost/limesurvey/.env <<EOF
HOST_INFO=http://localhost:8130
ADMIN_EMAIL=admin@localhost
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
ENCRYPT_NONCE=$(openssl rand -hex 24)
ENCRYPT_SECRET_BOX_KEY=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/limesurvey/.env
umask 022
ls -l ~/selfhost/limesurvey/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same on all three
systems. Those 24 and 32 hex bytes are the lengths LimeSurvey's own key generator produces;
change either later and encrypted records stop decrypting for good. On Windows the mode bits are
advisory and the user's own account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/limesurvey/compose.yml <<'EOF'
# LimeSurvey · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   config reference . https://www.limesurvey.org/manual/Optional_settings
#   image repo ....... https://github.com/martialblog/docker-limesurvey/tree/7.0.7-260729
#
# LimeSurvey publishes no Docker image; martialblog/limesurvey is a community
# image, MIT, whose Dockerfile fetches the official 7.0.7+260729 tarball and
# checks its sha256. Both mounts are named volumes: MariaDB chowns its data dir
# to its own uid, which Docker Desktop cannot grant on a Windows home-directory
# bind mount, and `upload` ships with base content a bind mount would hide.
# ${...} comes from ./.env, mode 600. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: limesurvey-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: limesurvey
      MARIADB_USER: limesurvey
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - limesurvey-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: martialblog/limesurvey:7.0.7-260729-apache@sha256:556d09839640f4702ee5ef6618a426c68f0688ded967b2805a0bd903a241f051
    container_name: limesurvey-app
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: limesurvey
      DB_USERNAME: limesurvey
      DB_PASSWORD: ${DB_PASSWORD}
      # Upstream's default engine; InnoDB caps a survey row near 8 KB.
      DB_MYSQL_ENGINE: MyISAM
      # The entrypoint exits without ADMIN_PASSWORD; these four seed it.
      ADMIN_USER: admin
      ADMIN_NAME: Site administrator
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      # Nothing terminates TLS here, so these links say http.
      HOST_INFO: ${HOST_INFO}
      # Written to config/security.php each start; changing either loses data.
      ENCRYPT_NONCE: ${ENCRYPT_NONCE}
      ENCRYPT_SECRET_BOX_KEY: ${ENCRYPT_SECRET_BOX_KEY}
    volumes:
      - limesurvey-upload:/var/www/html/upload
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/index.php/admin/authentication/sa/login || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only. The container listens on 8080, as www-data.
      - "127.0.0.1:8130:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  limesurvey-db:
  limesurvey-upload:
EOF
cd ~/selfhost/limesurvey && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one port, two named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no
hostname to resolve. A certificate attests a public name and nothing here has one; browsers
treat http://localhost as a secure context anyway, so pages needing crypto still work. Nothing is published beyond loopback, so no port needs closing.

8130 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. That is the point of this path, not a defect in it.

```bash
grep -n '127.0.0.1' ~/selfhost/limesurvey/compose.yml
```

Assert: two lines, the container's healthcheck and `- "127.0.0.1:8130:8080"`. MariaDB publishes
no host port, so 3306 cannot appear.

## 7. Start and verify

On the first start the entrypoint waits for MariaDB, writes LimeSurvey's config, then runs the
console installer. Read step 10 before interpreting that log.

```bash
cd ~/selfhost/limesurvey
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8130/index.php/admin/authentication/sa/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8130/index.php/admin/authentication/sa/login | grep -c 'x-test id="action::login"'
curl -sS http://localhost:8130/index.php/installer | grep -c 'Installation has been done already'
docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from lime_users" "$MARIADB_DATABASE"'
```

Assert all four, printing what you received for each: the loop ends on `200`; the second prints
`1`, the marker LimeSurvey's test suite looks for to confirm the login page rendered, so PHP
reached the database; the third prints `1`, the security assert here, because a config file
exists and the installer now refuses whoever opens that address; the fourth prints `1`, the one
administrator the console installer made. If any misses, stop, run
`docker compose logs --tail 60 app` and `docker compose logs --tail 20 db`, and name the cause:
a database that never reports healthy points at step 4; `port is already allocated` means
something else holds 8130. A running container is not success.

The first screen at http://localhost:8130/index.php/admin shows the heading `Administration`
above the words `Log in`, with a username and a password field.

STOP: tell the user to read their administrator password with
`grep ADMIN_PASSWORD ~/selfhost/limesurvey/.env`, put it in their password manager, sign in at
http://localhost:8130/index.php/admin as the user `admin`, and wait. Do not continue until they
confirm.

## 8. First backup and restore

Three artifacts: a dump of every survey, question and response, an archive of themes, plugins
and uploads, and a config archive with the encryption keys the other two need.

```bash
cd ~/selfhost/limesurvey
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/limesurvey/backups/limesurvey-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - upload > ~/selfhost/limesurvey/backups/limesurvey-upload-$(date +%F).tar.gz
tar -C ~/selfhost/limesurvey -czf ~/selfhost/limesurvey/backups/limesurvey-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/limesurvey/backups/
```

Assert: all three exist and are non-empty, all three sizes printed. The dump read-locks each
table as it reads it: the survey tables are MyISAM, with no transaction to snapshot.

All three 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 all three there with `cp`. Assert: the user confirms all three
are listed there, or say plainly that this install has no backup.

To restore, untar the config archive into ~/selfhost/limesurvey first, so .env is back before
any container starts: MariaDB reads its password from it the moment it initialises an empty
volume, and its encryption values are the only way the restored data decrypts. Then
`docker compose down -v`, the one place `-v` belongs, `docker compose up -d db`, wait 30 seconds
for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`, then the uploads with
`docker compose exec -T app tar -C /var/www/html -xzf - < backups/limesurvey-upload-<date>.tar.gz`.
Sign in once and open a survey. That is the whole disaster plan.

## 9. Updating later

Versions are listed at https://github.com/LimeSurvey/LimeSurvey/tags and the image tags at
https://github.com/martialblog/docker-limesurvey/tags. Take all three backups first, then edit
the app image line in ~/selfhost/limesurvey/compose.yml:

```bash
cd ~/selfhost/limesurvey
docker compose pull
docker compose up -d
docker compose logs --tail 40 app
```

The entrypoint runs `console.php updatedb` on every start, so a version bump migrates the schema
itself. Watch that log, then re-run step 7's four checks.

## 10. What will probably go wrong

I set a survey live on a Friday, closed the laptop, and found nothing waiting on Monday. Nothing
was broken: the machine was asleep, so 8130 answered nobody and everyone who opened the link got
a connection error instead of question one. `restart: unless-stopped` acts only once the Docker
daemon is up, and the daemon is up only while the computer is. Turn on Docker Desktop's
start-at-login setting, and treat this as a place to build surveys, not to leave one collecting.

## 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 `HOST_INFO` to this machine's LAN address and do not rebind 8130 to 0.0.0.0 so
  a phone can reach it. That puts an admin login on every network the user joins.
- Do not configure SMTP. Anonymous link surveys are the whole product without it.
- Do not use ComfortUpdate or the in-app updater. This container is pinned by digest.
````

## docker-compose.yml

```yaml
# LimeSurvey · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   requirements ..... https://www.limesurvey.org/manual/Installation_-_LimeSurvey_CE
#   config reference . https://www.limesurvey.org/manual/Optional_settings
#   image README ..... https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md
#   image entrypoint . https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/7.0/apache/entrypoint.sh
#
# The LimeSurvey project publishes no Docker image. martialblog/limesurvey is a
# community image, MIT, maintained outside that project; its Dockerfile fetches
# the official LimeSurvey 7.0.7+260729 tarball and checks its sha256.
#
# Two services: Apache with PHP, and the MariaDB it keeps surveys and responses
# in. Every ${...} comes from /srv/limesurvey/.env, mode 600. `upload` is a named
# volume because the image ships that directory's base content, which a bind
# mount would hide. Digests read 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: limesurvey-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: limesurvey
      MARIADB_USER: limesurvey
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - /srv/limesurvey/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: martialblog/limesurvey:7.0.7-260729-apache@sha256:556d09839640f4702ee5ef6618a426c68f0688ded967b2805a0bd903a241f051
    container_name: limesurvey-app
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: limesurvey
      DB_USERNAME: limesurvey
      DB_PASSWORD: ${DB_PASSWORD}
      # Upstream's default engine for the wide table each survey gets. InnoDB
      # caps a row near 8 KB, which a long questionnaire goes past.
      DB_MYSQL_ENGINE: MyISAM
      # The entrypoint exits without ADMIN_PASSWORD; these four seed the
      # LimeSurvey console installer once, on first boot.
      ADMIN_USER: admin
      ADMIN_NAME: Site administrator
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      # Caddy terminates TLS, so LimeSurvey is told the scheme and host it
      # should build absolute links from.
      HOST_INFO: ${HOST_INFO}
      # Written to application/config/security.php on every start. Change
      # either one and encrypted participant data stops decrypting.
      ENCRYPT_NONCE: ${ENCRYPT_NONCE}
      ENCRYPT_SECRET_BOX_KEY: ${ENCRYPT_SECRET_BOX_KEY}
    volumes:
      - limesurvey-upload:/var/www/html/upload
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/index.php/admin/authentication/sa/login || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8130.
      # The container listens on 8080 and runs as www-data, not root.
      - "127.0.0.1:8130:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  limesurvey-upload:
```

## compose.local.yml

```yaml
# LimeSurvey · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   config reference . https://www.limesurvey.org/manual/Optional_settings
#   image repo ....... https://github.com/martialblog/docker-limesurvey/tree/7.0.7-260729
#
# LimeSurvey publishes no Docker image; martialblog/limesurvey is a community
# image, MIT, whose Dockerfile fetches the official 7.0.7+260729 tarball and
# checks its sha256. Both mounts are named volumes: MariaDB chowns its data dir
# to its own uid, which Docker Desktop cannot grant on a Windows home-directory
# bind mount, and `upload` ships with base content a bind mount would hide.
# ${...} comes from ./.env, mode 600. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: limesurvey-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: limesurvey
      MARIADB_USER: limesurvey
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
      MARIADB_DISABLE_UPGRADE_BACKUP: "1"
    volumes:
      - limesurvey-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  app:
    image: martialblog/limesurvey:7.0.7-260729-apache@sha256:556d09839640f4702ee5ef6618a426c68f0688ded967b2805a0bd903a241f051
    container_name: limesurvey-app
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: limesurvey
      DB_USERNAME: limesurvey
      DB_PASSWORD: ${DB_PASSWORD}
      # Upstream's default engine; InnoDB caps a survey row near 8 KB.
      DB_MYSQL_ENGINE: MyISAM
      # The entrypoint exits without ADMIN_PASSWORD; these four seed it.
      ADMIN_USER: admin
      ADMIN_NAME: Site administrator
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      # Nothing terminates TLS here, so these links say http.
      HOST_INFO: ${HOST_INFO}
      # Written to config/security.php each start; changing either loses data.
      ENCRYPT_NONCE: ${ENCRYPT_NONCE}
      ENCRYPT_SECRET_BOX_KEY: ${ENCRYPT_SECRET_BOX_KEY}
    volumes:
      - limesurvey-upload:/var/www/html/upload
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/index.php/admin/authentication/sa/login || exit 1"]
      start_period: 30s
      interval: 15s
      retries: 20
    ports:
      # Loopback only. The container listens on 8080, as www-data.
      - "127.0.0.1:8130:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  limesurvey-db:
  limesurvey-upload:
```

## Caddyfile

```text
# LimeSurvey · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also HOST_INFO in .env, and it is the
# address inside every survey link you hand out.

<DOMAIN> {
	encode zstd gzip

	# LimeSurvey sets its own framing and content-security headers on the
	# admin side; these are the rest. The referrer is trimmed because a
	# shared survey link would otherwise carry its token onward.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8130 is the loopback port compose publishes on this host, not a
	# container port and not open in the firewall. Caddy passes the Host
	# header through, which is what the image README asks of a proxy in
	# front of LimeSurvey, and the image reads a response's client address
	# from X-Real-IP through Apache's mod_remoteip.
	reverse_proxy 127.0.0.1:8130 {
		header_up X-Real-IP {remote_host}
	}
}
```

## install.sh

```bash
#!/usr/bin/env bash
# LimeSurvey · 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=survey.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://www.limesurvey.org/manual/Installation_-_LimeSurvey_CE
#   https://www.limesurvey.org/manual/Optional_settings
#   https://www.limesurvey.org/manual/Data_encryption
#   https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/README.md
#   https://github.com/martialblog/docker-limesurvey/blob/7.0.7-260729/7.0/apache/entrypoint.sh
#
# The LimeSurvey project publishes no Docker image. martialblog/limesurvey is a
# community image, MIT, maintained outside that project; its Dockerfile fetches
# the official LimeSurvey 7.0.7+260729 tarball and checks its sha256.
#
# Five secrets are generated here, on this machine: the limesurvey database
# password, the MariaDB root password, the administrator's password, and the two
# data-encryption values. All five go into /srv/limesurvey/.env with mode 600 and
# none is ever printed. Change either encryption value later and encrypted
# participant records stop decrypting, with no recovery path.
#
# DOMAIN_HOST is also HOST_INFO, the address inside every survey link you send.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/limesurvey}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

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. survey.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address for the administrator account"
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 1024 ] || die "only ${avail_mb} MB of RAM available; PHP and MariaDB want 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------
#
# mariadb stays owned by root: the MariaDB image chowns its own data directory
# and refuses one somebody claimed first. LimeSurvey's upload tree is a named
# volume, because the image ships that directory's base content and an empty
# host folder mounted over it would hide the themes and plugins in it.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the five secrets, on the server -----------------------------
#
# Hex for the three that travel inside connection strings and config files,
# base64 for the one a human types into a login form. Read them later with
#   sudo grep -E 'DB_PASSWORD|ADMIN_PASSWORD' /srv/limesurvey/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		HOST_INFO=https://${DOMAIN_HOST}
		ADMIN_EMAIL=${ADMIN_EMAIL}
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -base64 24)
		ENCRYPT_NONCE=$(openssl rand -hex 24)
		ENCRYPT_SECRET_BOX_KEY=$(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-limesurvey"
	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 8130 nor 3306 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; 8130 and 3306 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 entrypoint waits for MariaDB, writes application/config/config.php and
# application/config/security.php, then asks an empty database whether it has
# been migrated. That question fails with a PHP stack trace on purpose, and the
# console installer runs next. The stack trace is not the error.

docker compose pull
docker compose up -d

LOGIN_URL="https://${DOMAIN_HOST}/index.php/admin/authentication/sa/login"
echo "==> waiting for ${LOGIN_URL}"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "$LOGIN_URL" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the login page answered ${code:-nothing}. Check: docker compose logs --tail 60 app"

curl -sS "$LOGIN_URL" | grep -q 'x-test id="action::login"' \
	|| die "that page answered 200 without the LimeSurvey login form. Check: docker compose logs --tail 60 app"

# The browser installer must refuse everyone now that a config file exists.
curl -sS "https://${DOMAIN_HOST}/index.php/installer" | grep -q 'Installation has been done already' \
	|| die "the installer did not report itself disabled. Stop and investigate before anyone finds that URL."

# Absolute links have to carry https and this hostname, or invitations will not.
docker compose exec -T app grep -q "'hostInfo' => 'https://${DOMAIN_HOST}'" application/config/config.php \
	|| die "config.php does not name https://${DOMAIN_HOST} as hostInfo"

# One administrator, made by the console installer rather than by a public form.
users="$(docker compose exec -T db sh -c 'exec mariadb -N -B -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "select count(*) from lime_users" "$MARIADB_DATABASE"' | tr -dc '0-9')"
[ "${users:-0}" -ge 1 ] || die "the users table is empty, so the console installer did not run"

# --- 7. The first backup, before day one ends --------------------------------
#
# Taken now, with an empty LimeSurvey, so the restore path is proved before there
# is anything to lose. The config archive carries the live Caddy site block, not
# the <DOMAIN> template, and the .env the encryption keys live in.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/limesurvey-db-${STAMP}.sql.gz"
docker compose exec -T app tar -C /var/www/html -czf - upload > "$APP_DIR/backups/limesurvey-upload-${STAMP}.tar.gz"
sudo tar -czf "$APP_DIR/backups/limesurvey-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/limesurvey-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/limesurvey-upload-${STAMP}.tar.gz" ] || die "the upload archive is empty"
[ -s "$APP_DIR/backups/limesurvey-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	LimeSurvey is answering at https://${DOMAIN_HOST}/index.php/admin

	  1. Sign in as the user "admin". The password is in $APP_DIR/.env, mode 600.
	     Read it with
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here. Changing
	     that line afterwards does nothing: it seeded the account once, and the
	     password now lives in the database and moves in the profile screen.
	  2. The browser installer is already disabled, and this script checked it:
	       curl -sS 'https://${DOMAIN_HOST}/index.php/installer'
	     answers with "Installation has been done already. Installer disabled."
	  3. Keep $APP_DIR/.env. ENCRYPT_NONCE and ENCRYPT_SECRET_BOX_KEY are written
	     into the container's security.php at every start, so they are how
	     encrypted participant data is read back. Lose them and it is gone.
	  4. First backup written to $APP_DIR/backups: a database dump, the upload
	     archive and a config archive. They are on the same disk as the data,
	     which is not a backup. Copy them off the box tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/limesurvey/
	  5. No mail is configured. Anonymous link surveys work; invitations,
	     reminders and password resets need SMTP, which this install does not set.

DONE
```

## Also evaluated

Ranked below LimeSurvey for this swap. The prompts above install LimeSurvey only.

- **Formbricks** — Link surveys and in-product feedback on your own domain, with no monthly response cap deciding your bill. The honest split is what you are measuring. Formbricks is built for in-product and link surveys, the NPS box that appears after somebody uses a feature, and it looks like software written this decade. It is not trying to be a research instrument: if your survey needs quota cells, randomised blocks or an SPSS file at the end, it will run out of room, and if it needs to look good embedded in your app tomorrow, LimeSurvey will.

The page this mirrors: https://caniselfhostit.com/self-host/surveymonkey/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
