Installation

> New to Joinery? The Quick Start guide walks you through renting a server, configuring your domain, and installing Joinery step by step — no prior experience required.

Deploy Joinery on a fresh Ubuntu 24.04 or 26.04 LTS server, either in a Docker container or directly on the host (bare-metal). The same install.sh script handles both — the deployment mode is auto-detected from whether a port is supplied. Docker mode is how you put several sites on one machine; it is not a sandbox — see What Docker mode is.

Table of Contents

  1. Quick Start
  2. Prerequisites
  3. Password Security
  4. Docker Deployment
  5. Bare-Metal Deployment
  6. SSL Certificates
  7. Cloudflare Proxy Support
  8. Themes and Plugins
  9. Site Cloning
  10. Domain Management
  11. Site Management
  12. Maintenance Operations
  13. Troubleshooting
  14. Script Reference

Quick Start

One-liner install (latest version)

Docker:

mkdir -p /tmp/joinery && \
  curl -sL https://getjoinery.com/utils/latest_release | tar xz -C /tmp/joinery && \
  cd /tmp/joinery/maintenance_scripts/install_tools && \
  sudo ./install.sh docker && \
  sudo ./install.sh site mysite example.com 8080

Bare-metal:

mkdir -p /tmp/joinery && \
  curl -sL https://getjoinery.com/utils/latest_release | tar xz -C /tmp/joinery && \
  cd /tmp/joinery/maintenance_scripts/install_tools && \
  sudo ./install.sh server && \
  sudo ./install.sh site mysite example.com

One-click deployment (Linode StackScript)

A StackScript installs Joinery while the instance first boots, so the deployer fills in a form and never opens a terminal. Select it when creating a Linode, answer the fields, and a few minutes later the site is running with SSL and a login.

The deploy form asks for as little as it can — every field is a chance for someone to abandon the form, and once this is a Marketplace listing each one is expensive to change:

FieldRequiredWhat it does
Admin email addressYesThe admin account's address. Password reset needs a mailbox someone can receive at.
Admin passwordYesThe password for that account. Masked in the UI and kept out of the deployment log, which is what the password in its field name buys. It is the password the owner keeps; no change is forced at first sign-in.
Your domainYesThe domain the site is installed as. A site with no domain can get no certificate and every link it emits names an IP address, so the form does not offer that state. install.sh run by hand still detects the IP when no domain is given.
Linode API tokenNoOnly useful when the domain's nameservers point at Linode. Needs the Domains Read/Write scope. Creates the zone when the account holds none, then the A record, from the instance, so the first certificate attempt succeeds rather than the retry timer's. Never printed. Once it has proved usable it is sealed into the site for the one DNS publish of the mail records — made during the install when a sending key is supplied, otherwise by the setup wizard's email step — and deleted on use.
Email sending API keyNoSets email up during the install, so the setup wizard's Email step opens on the delivery proof (or a DNS wait) instead of an empty form. The provider is told from the key: every provider one key configures declares the shape of its keys (SingleKeyProvider: SMTP2GO, Mailgun, SendGrid, Resend, Brevo, Postmark), and a key no shape matches is tried live against each. utils/install_mail_provider.php then runs the wizard's own ceremony: the From address is derived from the admin address on the site's domain, the owner's mailbox is provisioned for it, the domain is registered at the provider where its API allows, its mail records are published through the kept Linode token, and the provider is asked to verify. A key every provider rejects leaves nothing configured and the wizard asks again; the closing summary says which happened. Never printed.
Backblaze B2 bucket, key ID, keyNoThree fields that together point backups at a bucket during the install. utils/install_backup_target.php creates the target, fills the region and endpoint from Backblaze's own answer, tests the connection, and makes it the scheduled target — the same as the wizard's "Save and test". A bucket that cannot be reached is removed again and the wizard asks for one. The recovery key that turns nightly backups on is a secret shown once to a human and stays the wizard's. The key is never printed.
The form declares no SSH key field. Root access is left as Linode configured it, and an operator who wants a key mirrored to user1 with root login disabled installs by hand with JOINERY_SSH_KEY in the environment, which the handoff script still honours. Region, plan and firewall are the deployer's own Create-form choices; a StackScript cannot preset or hide them, and every field it declares is shown.

Neither optional service is a condition of the install. The StackScript only passes these fields on: _site_init.sh does the work, so a hand-run install.sh site takes the same inputs (see "Services set up at install" below).

Nothing is asked that can be worked out. The site name comes from the domain (or the instance ID); the install is always bare-metal, one site per instance.

There is no credentials file on this path — the owner already knows the password, because they chose it. Every other install writes one, since nobody chose that password.

An instance built this way is entirely the deployer's: no agent, no registration, no enrollment, no outbound call beyond fetching the release archive.

How it is put together. The script hosted at Linode is a wrapper of about twenty lines: it declares the fields, fetches the release archive, and hands off to maintenance_scripts/install_tools/linode_stackscript.sh inside it. All the real logic lives in the archive, so it ships with every release and an instance created today installs what was published this morning — with nothing to update on the Linode side. The pasted wrapper is kept in the repo at maintenance_scripts/install_tools/linode_stackscript_wrapper.sh so it stays reviewable.

If a step fails the script stops and says so in /var/log/stackscript.log, rather than continuing into a half-installed box that looks alive. The remedy is to destroy the instance and redeploy with the field corrected.

Manual transfer

# Copy the archive to the target server
scp joinery-X-Y.tar.gz root@YOUR_SERVER:~/
ssh root@YOUR_SERVER
tar -xzf joinery-X-Y.tar.gz
cd maintenance_scripts/install_tools

# One-time host setup
sudo ./install.sh docker         # OR: sudo ./install.sh server

# Create your first site (password auto-generated — save it!)
sudo ./install.sh site mysite mysite.com 8080   # Docker (with port)
sudo ./install.sh site mysite mysite.com        # Bare-metal (no port)

The presence of a port signals Docker mode; omitting it signals bare-metal. To force either explicitly, use --docker or --bare-metal. The resolved mode is recorded in the site's Globalvars_site.php as deployment_environment (docker or baremetal) — the single source of truth the platform reads instead of probing for a container at runtime.

Prerequisites

Server requirements

  • Fresh Ubuntu 24.04 or 26.04 LTS — install.sh server refuses to run on anything else. It installs whichever PHP the release offers and derives every package, service, and config path from that, so no version is pinned; the gate is about which releases the package and service layout has been verified on. On an unverified release the setup can leave a server that does not work while looking like it installed. To proceed anyway and finish the setup by hand, pass --allow-unsupported-os; the check is not repeated by install.sh site, which presupposes server already ran.
  • Root access
  • 1 GB RAM minimum
  • 3 GB disk minimum
  • Docker mode: port 8080+ available (or your chosen range)
  • Bare-metal mode: ports 80 / 443 available

Archive contents

The joinery-X-Y.tar.gz archive contains:

  • public_html/ — application code
  • config/ — configuration templates
  • maintenance_scripts/install_tools/ — installer, Dockerfile, defaults
  • maintenance_scripts/sysadmin_tools/ — backup, restore, maintenance utilities

Password Security

Never use weak or example passwords in production. Auto-generation is the recommended path.

Omit the password and the installer generates a 24-character secure password, then displays it once at the end of installation:

sudo ./install.sh site mysite mysite.com 8080
# Output: "Auto-generated secure password: xK9mN2pQ7rT4vW8yB3cF6hJ1"

Save the password immediately — it's also written to the site's Globalvars_site.php.

Bring your own password

Hand it over in a file so it never appears on a command line:

echo 'YourStr0ng&Secure#Pass@9' > /tmp/dbpass.txt
sudo ./install.sh site mysite --password-file=/tmp/dbpass.txt mysite.com 8080
rm /tmp/dbpass.txt

Any character works

Quotes, backslashes, dollar signs, backticks, exclamation marks, spaces: all fine. The one value the password cannot hold is a line break, because it travels through single-line files.

The password is never pasted into a command. It reaches PostgreSQL as a quoted SQL literal on psql's standard input, reaches Globalvars_site.php through a PHP writer that emits a correct string literal, and reaches the database driver as a connection argument rather than part of a connection string.

What can still alter it is your own shell, before the installer runs: ! triggers history expansion in an interactive bash session, and $ and backticks expand inside double quotes. A file, or a single-quoted environment value, sidesteps that:

sudo POSTGRES_PASSWORD='It'"'"'s $12.50 & a "quote"!' ./install.sh site mysite mysite.com 8080

Requirements

  • Minimum 16 characters (24+ recommended)
  • Mix of upper, lower, digits, symbols
  • No dictionary words, personal info, sequential patterns, or example passwords

Non-interactive deployment

sudo ./install.sh -y docker
sudo ./install.sh -y -q site mysite mysite.com 8080

-y accepts all prompts; -q suppresses progress output. Both flags work before or after the subcommand — write them first, as above, so every example reads the same way. An unknown flag stops the install with a message.

Without -y, a run with no terminal on stdin (cloud-init, CI, piped ssh) still completes: every prompt takes its default. Defaults are conservative — proposals (install Docker, use a suggested port) proceed; destructive choices (overwrite an existing site, delete data volumes, downgrade code) refuse, and only their explicit flags (--wipe-data, --allow-downgrade) can say otherwise. The one hard requirement is the bare-metal server setup's database password, which must arrive via POSTGRES_PASSWORD in the environment when nobody can type it.

Docker Deployment

What Docker mode is

Docker mode is how you run several Joinery sites on one machine. Each site gets a container holding a complete small server — Apache, PHP-FPM, PostgreSQL, cron, and whatever daemons its plugins need — with its own database, its own files and its own host port. The container's job is to keep sites from colliding with each other over ports, database names and file paths.

The container is not a security boundary, and a site is not a sealed appliance that can be dropped safely onto a shared machine. Treat every site on a host, and the host itself, as one trust domain:

  • The sites share a network. Containers run on Docker's default bridge, so each one can reach the others, the host, and whatever else the host can reach. One site's code can open a connection to another site's PostgreSQL.
  • The host is part of the site. HTTPS terminates on the host's Apache, which proxies to the container over plain HTTP on loopback. The certificate, the proxy vhost and the site's DNS all live outside the container.
  • docker access is access to every site. Site data lives in named volumes on the host, docker exec opens a root shell in any container, and membership of the docker group is equivalent to root on the machine.
  • Containers take Docker's defaults. No user-namespace remapping, no read-only root filesystem, no dropped capabilities. Processes start as root inside the container; Apache and PHP-FPM drop their workers to www-data.
  • The site's web port answers on every interface. -p PORT:80 publishes there, and Docker's forwarding rules are consulted before UFW's, so a UFW rule does not close it — anyone who knows the port reaches the site directly, skipping the host proxy and its HTTPS redirect. The container's PostgreSQL port is the exception: it publishes on loopback only, and install.sh docker adds a DOCKER-USER rule dropping ports 9080-9099 arriving on the public interface.
install.sh docker also does the host's housekeeping: fail2ban (see fail2ban and the real client address), a 100M cap on the system journal, Docker BuildKit garbage collection, 1G of encrypted swap, apport off, and cleared failed-login logs. It does not configure UFW on a Docker host — the UFW rules in this guide belong to bare-metal server setup.

So run Joinery containers on a machine you control, and put sites that must not reach each other on separate machines rather than in separate containers.

One-time setup

sudo ./install.sh docker

Checks for Docker, installs Docker CE if missing, starts the daemon, verifies it's operational.

Create a site

sudo ./install.sh site SITENAME [DOMAIN_NAME] [PORT] [OPTIONS]

ParameterRequiredDefaultNotes
SITENAMEYesSite & database name (e.g., mysite)
DOMAIN_NAMENoServer IPDomain for VirtualHost
PORTNo8080Host port for web traffic
The installer:

  1. Validates parameters and archive structure.
  2. Checks port availability and suggests alternatives on conflict.
  3. Prepares an isolated build context.
  4. Builds the Docker image.
  5. Starts the container with persistent volumes.
  6. Verifies the site responds — the probe carries the configured domain in its Host: header, so a green check means a visitor can load the site, not merely that Apache is up. A redirect to an HTTPS vhost the install did not create fails the check.
  7. Optionally downloads stock themes/plugins (with --themes).
  8. Displays access info and the list of running containers.

Multi-site support

Each site needs unique ports. The installer detects conflicts and suggests the next pair:

SiteWeb portDB port
site180809080
site280819081
site380829082

Volume mounts

VolumeContainer pathPurpose
{site}_postgres/var/lib/postgresqlDatabase files
{site}_uploads.../uploadsUser uploads
{site}_config.../configSite configuration
{site}_backups.../backupsDatabase backups
{site}_static.../static_filesGenerated files
{site}_logs.../logsApplication logs
{site}_cache.../cacheRuntime cache
{site}_sessions/var/lib/php/sessionsPHP sessions
{site}_apache_logs/var/log/apache2Apache logs
{site}_pg_logs/var/log/postgresqlPostgreSQL logs

Bare-Metal Deployment

One-time setup

sudo ./install.sh server

Installs and configures PHP 8.3, Apache (with mod_rewrite), PostgreSQL, Composer, Certbot, UFW, fail2ban, SSH hardening, and unattended security updates.

How SSH hardening picks its account

Turning off root SSH login is the one hardening step that can lock an operator out, so the installer works out who will still be able to reach the box before it does that. Everything else — MaxAuthTries 3, empty passwords refused, idle-session timeouts, fail2ban, UFW — is applied unconditionally.

What the installer findsWhat it does
Running as root, and /root/.ssh/authorized_keys has keysCopies those keys to user1, grants it passwordless sudo, then sets PermitRootLogin no.
Running under sudo from an ordinary accountThat account already has its own key and sudo, so it sets PermitRootLogin no and does nothing else.
Neither — root reached by password, no key installedLeaves PermitRootLogin alone and says so. Disabling it here would leave nothing able to log in.
The third case is the only one that finishes with root password login still enabled. It is what you get on a provider that boots you a machine with a root password and no SSH key attached. On a self-hosted machine that password is the owner's only way in, so the installer leaves it on and relies on the fail2ban jail (three failures in ten minutes, banned for an hour) to limit guessing. On a machine a management node provisioned, the management node turns password login off itself once the machine's agents are admitted; see the Server Manager plugin's retire_install_password job.

fail2ban and the real client address

maintenance_scripts/install_tools/host_housekeeping.sh is the one implementation of "fail2ban is configured". It is a core host installer, so it runs at install, on every converge of the host timer (_plugin_installers_start.sh, CORE_INSTALLERS), through the runner alone with sudo bash /var/www/html/SITE/maintenance_scripts/install_tools/_plugin_installers_start.sh --only=host_housekeeping.sh (the same lock and ownership check the timer's run gets, nothing else run), and by hand:

sudo bash /var/www/html/SITE/maintenance_scripts/install_tools/host_housekeeping.sh

What it leaves behind, every time: /etc/fail2ban/jail.d/joinery-sshd.local (the SSH jail: three failures in ten minutes, banned for an hour) and, where Apache is installed and logs the real client (the mod_remoteip step below is in place), jail.d/joinery-apache.local (apache-auth, apache-badbots, apache-noscript, apache-overflows, each on the file backend and watching /var/www/html/*/logs/ — a site's access.log/error.log and a Docker host's proxy_access.log/proxy_error.log — as well as /var/log/apache2/). Both are written whole, never appended to. Without the remoteip configuration an Apache log names the peer, which behind Cloudflare is an edge, so the Apache drop-in is removed rather than written and the output says why; the SSH jail is configured regardless. A /etc/fail2ban/jail.local that is jail.conf followed only by section headers, enabled lines and a ban policy (bantime, findtime, maxretry) for jails the drop-ins carry is deleted — that is a copy-and-append "enable fail2ban" in any wording, it repeats sections jail.conf already declares, and fail2ban 1.0.2 refuses to start on a repeated section; one with any other content is left alone and named in the output, with a warning when it repeats a section itself. The script then proves the service: it exits non-zero, with the journal's last lines, when fail2ban is not active afterwards or the sshd jail does not answer. SSH posture (PasswordAuthentication, PermitRootLogin) is read from sshd -T and reported, never changed.

The range list is read from the site tree the script lives in (SITE_ROOT/public_html/includes/cloudflare_ip_ranges.txt). On a Docker host, whose site trees are inside the containers, run it from a checkout of the code or name the tree: sudo bash host_housekeeping.sh "" /path/to/checkout.

A jail bans whatever address the log names, so the log must name the real client, and must name it only when every hop between the client and Apache is a proxy we know. The same script enables mod_remoteip with conf-available/joinery-remoteip.conf: X-Forwarded-For is trusted from Cloudflare's published edge ranges (public_html/includes/cloudflare_ip_ranges.txt, the one list SessionControl also trusts a CF-Connecting-IP from) and, inside a container, from the host's reverse proxy on the Docker bridge; the combined and vhost_combined log formats record that resolved client (%a). A forged forwarding header on a direct connection stops at the untrusted peer, so a ban can never be steered onto a third party. The Docker host's proxy vhost appends its peer to X-Forwarded-For rather than replacing it, so the container walks the chain right to left to the client. Behind an edge a ban is inert (the connections come from the edge); on a directly reached site it lands on the attacker.

Create a site

sudo ./install.sh site SITENAME DOMAIN_NAME [OPTIONS]

Common options:

  • --admin-email=ADDRESS — the admin account's address. Set at the same moment as its password, so the only account on a new site is recoverable by email from the start. Omitted, the account is [email protected].
  • --activate THEME — activate a specific theme after install
  • --with-test-site — create a companion test site (bare-metal only)
  • --upgrade-server=URL — fetch the code from somewhere other than the release site (see Where a site gets its upgrades)
The installer:

  1. Verifies prerequisites (Apache, PHP, PostgreSQL).
  2. Deploys code to /var/www/html/{sitename}/.
  3. Runs _site_init.sh to create directories, configure Globalvars_site.php, create the database, load the schema, record where upgrades come from, install Composer deps, install the default plugin bundle, and create the Apache VirtualHost.
  4. Optionally creates a test site.
  5. Verifies the site responds.

What a new site comes with

A fresh install is not the bare platform. Drive and the personal calendar are core and always present; on top of them the installer turns on a bundle — a named set of plugins declared in install_bundles.json at the public_html/ root.

The default bundle is personal: mail and the AI assistant, which together with Drive and Calendar make the deployment a self-hosted replacement for the everyday Google tools. Everything else — events, commerce, bookings, the password vault, DNS filtering, server management — is installed from /admin/admin_plugins when it is wanted.

Both bundled plugins arrive installed and unconfigured, and each needs the owner to supply something before it does anything: mail needs MX and DKIM records and an outbound provider, the assistant needs a model provider.

# choose a different bundle at install time
JOINERY_INSTALL_BUNDLE=personal sudo ./install.sh site mysite mysite.com

# or apply one to an existing site
sudo php /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/install_bundle.php --list
sudo php /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/install_bundle.php --bundle=personal

Services set up at install

Three things the setup wizard would otherwise ask for can be handed to install.sh site in the environment, on every path (bare metal, Docker, the StackScript). _site_init.sh honours them on fresh installs, after the plugin bundle, and none is a condition of the install: a failure is recorded and the wizard asks for that one again.

VariableWhat it does
JOINERY_MAIL_API_KEY (+ JOINERY_MAIL_PROVIDER to name the provider, else detected from the key; JOINERY_MAIL_FROM, default derived from the admin address on the site's domain)Email is set up by utils/install_mail_provider.php, the wizard's own ceremony: From address, the owner's mailbox, the domain registered at the provider where its API allows, its mail records published through the kept DNS credential, the provider asked to verify. The wizard then opens on the delivery proof.
JOINERY_BACKUP_BUCKET + JOINERY_BACKUP_KEY_ID + JOINERY_BACKUP_KEY (+ JOINERY_BACKUP_PROVIDER b2/s3/linode, default b2; JOINERY_BACKUP_REGION for s3 and linode)utils/install_backup_target.php creates the target, tests it, and makes it the scheduled one. The recovery key stays the wizard's.
JOINERY_DNS_CREDENTIALThe JSON utils/install_dns_credential.php takes, e.g. {"driver":"linode","credential":{"access_token":"…"}}, kept for the one publish of the mail records and deleted on use.
JOINERY_MAIL_API_KEY=api-… \
JOINERY_BACKUP_BUCKET=joinerybackups JOINERY_BACKUP_KEY_ID=… JOINERY_BACKUP_KEY=… \
sudo -E ./install.sh site mysite mysite.com

Outcomes land in {site root}/config/install_services.txt (mail=, backup=, dns_credential=, each done:, failed: or skipped:), which the closing summary reads; the first-task email notice is printed only when email was not set up. On Docker, every JOINERY_* input _site_init.sh reads crosses into the container from one list in install.sh (SITE_INIT_ENV_INPUTS). Each tool is CLI-only, reads only the environment, and can be run by hand on an existing site.

JOINERY_INSTALL_BUNDLE=none installs no plugins. Bundles are flat lists and never extend one another — they are alternative products rather than layers, so each names everything it wants.

Where a site gets its upgrades

Two separate things, which the installer keeps in agreement:

  • --upgrade-server=URL tells this run where to fetch the archive from. It defaults to https://getjoinery.com, the release site.
  • upgrade_source, a setting on the finished site, tells upgrade.php where to fetch from every time after.
_site_init.sh writes the second from the first, so whatever a site was installed from is what it upgrades from. Nothing to configure and nothing to keep in sync: pass --upgrade-server and both follow, leave it off and the site tracks stable releases.

Cloned sites are the exception — they carry the source site's upgrade_source, which is the right answer for a copy of that site.

Directory layout

/var/www/html/{sitename}/
├── public_html/      # Application code
├── config/           # Site configuration
├── uploads/          # User uploads
├── logs/             # Application logs
├── static_files/     # Generated files
└── backups/          # Database backups

SSL Certificates

SSL is configured automatically when a domain (not localhost or an IP) is provided.

How it works

  1. The installer checks whether the domain's DNS points to this server.
  2. If it does, Certbot runs to fetch a Let's Encrypt certificate.
  3. If it doesn't, the install goes ahead anyway and no certificate is issued. The vhost guards its :443 block with <IfFile>, so a missing certificate means the site serves HTTP rather than Apache refusing to start.
DNS not being ready never stops an install, and it does not leave you anything to remember either.

The retry timer

An install that could not issue a certificate leaves behind a systemd timer, joinery-ssl-retry@{domain}, that finishes the job whenever DNS lands — minutes later or a week later. Nothing needs to be run by hand.

Each run resolves the domain first and only invokes Certbot when the A record actually points at this server. That is what makes an open-ended retry safe: Let's Encrypt allows five failed validations per hostname per hour, and a DNS lookup that comes back empty costs nothing against that budget. On a CA-issued certificate the timer disables itself and removes its config.

sudo systemctl list-timers 'joinery-ssl-retry@*'      # is one pending
sudo journalctl -fu [email protected]   # what it is seeing

Its state is a single file per domain at /etc/joinery/ssl-retry/{domain}.conf. Delete it to stop the retries.

To issue immediately rather than wait for the next check:

sudo /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/setup_ssl.sh mysite.example.com

Requirements: domain DNS pointing here, port 80 reachable from the internet, Certbot installed (included in install.sh server).

Bare-metal

Certbot configures Apache directly:

sudo ./install.sh site mysite mysite.example.com

Docker

The installer adds Apache on the host (if not present), creates a reverse proxy mysite.example.com → localhost:8080, then runs Certbot against the proxy:

sudo ./install.sh site mysite mysite.example.com 8080

Skip SSL

sudo ./install.sh site mysite mysite.example.com --no-ssl

What --no-ssl gives you: a site that answers on plain HTTP for its domain. No HTTP→HTTPS redirect exists until a certificate does — the vhost's redirect is gated on the certificate file, so requests carrying the real domain load over HTTP rather than bouncing into a :443 vhost that isn't there.

Manual SSL later

sudo /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/setup_ssl.sh mysite.example.com

Works for both modes — Docker sites terminate TLS at the host's reverse proxy, which is the same Apache the script reloads. It tries an HTTP-01 challenge, falls back to DNS-01 when a provider credential file is present at /etc/letsencrypt/<provider>.ini, and leaves the site on HTTP if neither succeeds.

Cloudflare Proxy Support

The installer detects domains behind Cloudflare's proxy (orange cloud) by matching the resolved IP against Cloudflare's IP ranges, and adapts:

  1. Skips Let's Encrypt — Cloudflare provides edge SSL.
  2. Creates an HTTP proxy for Docker sites so Cloudflare can reach the origin.
Set the SSL mode in Cloudflare → SSL/TLS:

ModeBrowser ↔ CloudflareCloudflare ↔ OriginOrigin cert
FlexibleHTTPSHTTPNone required
FullHTTPSHTTPS (any cert)Self-signed OK
Full (Strict)HTTPSHTTPS (valid cert)Cloudflare Origin Certificate
For Full (Strict), generate an Origin Certificate in Cloudflare → SSL/TLS → Origin Server and install it on Apache.

Themes and Plugins

By default, fresh installs include only the core application. Use --themes to download stock themes and plugins from the upgrade server during site creation:

sudo ./install.sh site mysite mysite.com 8080 --themes

To download themes and plugins after the site exists, use upgrade.php:

# Docker
docker exec mysite php /var/www/html/mysite/public_html/utils/upgrade.php

# Bare-metal
php /var/www/html/mysite/public_html/utils/upgrade.php

The --themes flag uses the same distribution system as upgrade.php. See Deploy and Upgrade for the upgrade pipeline.

Site Cloning

Clone an existing site — database, uploads, settings — to a new server. The target machine pulls from the source.

Enable export on the source

clone_export_key is a managed setting: it is declared, but kept off the settings page so that an admin cannot turn on a full-site export from a browser. It is written in one of two ways.

A management node that provisions a clone of a site it manages arms the source itself, through the source agent's clone_export_arm primitive (the setting name is compiled into utils/clone_export_arm.php on the source; the plane sends only the key), and disarms it the same way when the provision ends. See the Server Manager docs.

By hand, in the source database:

UPDATE stg_settings SET stg_value = 'YourSecureRandomKey123' WHERE stg_name = 'clone_export_key';

-- When done:
UPDATE stg_settings SET stg_value = '' WHERE stg_name = 'clone_export_key';

Use a strong random key (32+ chars, letters, digits, _ and -). HTTPS is required. Clear the key after cloning. Clone requests are logged on the source. The key is also the password the database dump is encrypted under in transit.

Run the clone

# Docker
sudo ./install.sh site newsite newdomain.com 8080 \
    --clone-from=https://sourcesite.com \
    --clone-key=YourSecureRandomKey123

# Bare-metal
sudo ./install.sh site newsite newdomain.com \
    --clone-from=https://sourcesite.com \
    --clone-key=YourSecureRandomKey123

What gets cloned

ItemBehavior
Database (all tables)Exact copy from source
All settingsExact copy from source
Uploads directoryExact copy from source
User accountsPreserved from source
clone_export_keyCleared on the new site
Globalvars_site.phpRegenerated with new DB credentials
Themes & pluginsDownloaded from the source site

Process

  1. Pre-flight: source reachable, key valid.
  2. Display manifest: DB size, uploads size, themes/plugins.
  3. Confirmation prompt (skip with -y).
  4. Deploy application code.
  5. Stream encrypted, compressed database; restore.
  6. Stream compressed uploads; extract.
  7. Update site URL.
  8. Standard setup: Composer, permissions, SSL.

Domain Management

Use manage_domain.sh (in maintenance_scripts/sysadmin_tools/) to add, change, or remove domains on existing sites. Works for both Docker and bare-metal.

cd maintenance_scripts/sysadmin_tools

# Current state
sudo ./manage_domain.sh status mysite

# Assign a domain (with SSL via Let's Encrypt unless Cloudflare detected)
sudo ./manage_domain.sh set mysite example.com

# Without SSL (e.g. Cloudflare-proxied or testing)
sudo ./manage_domain.sh set mysite example.com --no-ssl

# Revert to IP-only access
sudo ./manage_domain.sh clear mysite

# Restore the previous configuration
sudo ./manage_domain.sh rollback mysite

# Remove SSL only, keep the domain
sudo ./manage_domain.sh remove-ssl mysite

For Docker sites, set creates an Apache reverse proxy on the host and disables 000-default.conf so bare-IP requests don't fall through to Ubuntu's welcome page.

Site Management

Docker container lifecycle

docker stop mysite
docker start mysite
docker restart mysite
docker ps --filter "name=mysite"

Logs

# Docker
docker logs mysite                                       # Startup
docker logs -f mysite                                    # Follow
docker logs --tail 100 mysite                            # Last 100
docker exec mysite tail -100 /var/www/html/mysite/logs/error.log

# Bare-metal
tail -f /var/www/html/mysite/logs/error.log
tail -f /var/log/apache2/access.log

Shell access

# Docker
docker exec -it mysite bash

# Bare-metal — just use the host shell
cd /var/www/html/mysite/

Apache management

In Docker, never service apache2 restart — it kills the container. Use reload or graceful:

docker exec mysite service apache2 reload
docker exec mysite apache2ctl graceful
docker exec mysite apache2ctl configtest

Bare-metal:

sudo systemctl reload apache2
sudo apache2ctl configtest

PostgreSQL access

# Docker
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" mysite \
    psql -h 127.0.0.1 -U postgres -d mysite

# Bare-metal
psql -U postgres -d mysite

Maintenance Operations

Database backup and restore

# Backup (Docker)
docker exec mysite pg_dump -U postgres mysite | gzip > backup.sql.gz

# Backup (bare-metal)
./maintenance_scripts/sysadmin_tools/backup_database.sh mysite

# Restore (Docker)
gunzip -c backup.sql.gz | docker exec -i mysite psql -U postgres -d mysite

# Restore (bare-metal)
./maintenance_scripts/sysadmin_tools/restore_database.sh mysite backup.sql

Run backup_database.sh with no database name and it backs up every database on the machine, except any that a site's config names as its dbname_test. A test database holds no content of its own — it is rebuilt from live on demand — so backing one up would ship a second encrypted copy of the site's data for nothing. The skips are listed in the run's output. The match is exact and a database that is some site's live dbname is never skipped, so a separate test site ({site}_test, with real content of its own) is always backed up.

Update application code

Docker — stop and re-create the container; volumes persist:

docker stop mysite && docker rm mysite
tar -xzf joinery-NEW-VERSION.tar.gz
cd maintenance_scripts/install_tools
sudo ./install.sh site mysite mysite.com 8080

The container detects this isn't a fresh install and skips initial setup.

Bare-metal — use upgrade.php:

php /var/www/html/mysite/public_html/utils/upgrade.php

For more detail on the upgrade pipeline, see Deploy and Upgrade.

Run database migrations

# Docker
docker exec mysite php /var/www/html/mysite/public_html/utils/update_database.php

# Bare-metal
php /var/www/html/mysite/public_html/utils/update_database.php

Remove a site

remove_account.sh detects whether the site is Docker or bare-metal and handles both:

sudo ./maintenance_scripts/sysadmin_tools/remove_account.sh mysite
sudo ./maintenance_scripts/sysadmin_tools/remove_account.sh mysite -y   # No prompt

Docker sitesBare-metal sites
Docker containerWebsite directories
All Docker volumes (postgres, uploads, etc.)Test site directories
Docker imageApache VirtualHost
Build directoryPostgreSQL database

Troubleshooting

Container won't start

docker logs mysite

Common causes: port already in use (the installer normally detects this and offers alternatives), volume permission issues, or out of disk space.

Services not running after a host restart

The container's CMD should bring services up automatically. If not:

docker exec mysite service postgresql start
docker exec mysite service apache2 start

Permission errors (bare-metal)

sudo chown -R www-data:user1 /var/www/html/mysite
sudo chmod -R 775 /var/www/html/mysite

# Or:
./fix_permissions.sh mysite --production

Database load failure during install

Almost always a syntax error, or a password the shell altered on its way in.

  1. If the password was typed on a command line, pass it with --password-file or POSTGRES_PASSWORD= instead. See Any character works.
  2. Verify any locally-modified joinery-install.sql.gz for SQL syntax.
  3. Confirm UTF-8 encoding on the SQL file.
pg_hba.conf settings, authentication method, and database user permissions are not the cause — the installer handles all of those.

Debugging:

docker logs mysite 2>&1 | grep -i "error\|fail"

docker exec -it mysite bash
su postgres -c "psql -d mysite -c '\\dt'"

Composer autoload errors after cloning

The composerAutoLoad setting was copied from the source and points to an invalid absolute path. Set it back to the portable relative path:

# Docker
docker exec -it mysite bash
PGPASSWORD='your_db_password' psql -U postgres -d mysite \
  -c "UPDATE stg_settings SET stg_value = '../vendor/' WHERE stg_name = 'composerAutoLoad';"

# Bare-metal
sudo -u postgres psql -d mysite \
  -c "UPDATE stg_settings SET stg_value = '../vendor/' WHERE stg_name = 'composerAutoLoad';"

Port conflict handling

If the chosen port is in use, the installer shows existing Joinery containers and suggests the next available port pair, then prompts you to accept.

Script Reference

install.sh

SubcommandPurpose
install.sh dockerInstall Docker (one-time)
install.sh serverSet up bare-metal host (one-time)
install.sh site …Create a new Joinery site
install.sh listList existing sites
Global flags:

FlagDescription
-y, --yesAuto-accept all prompts (non-interactive)
-q, --quietSuppress progress output; show errors and final summary
install.sh site options:

install.sh [-y] [-q] site [--docker|--bare-metal] SITENAME [DOMAIN] [PORT] [OPTIONS]

  --password-file=FILE   Read database password from file (recommended)
  --activate THEME       Activate this theme after install
  --with-test-site       Create a companion test site (bare-metal only)
  --themes               Download stock themes/plugins from upgrade server
  --no-ssl               Skip automatic SSL setup
  --clone-from=URL       Clone DB + uploads from an existing site
  --clone-key=KEY        Authentication key for clone source

If no password is given (and no --password-file), the installer auto-generates a 24-character password.

Supporting scripts

ScriptPurposeCalled by
_site_init.shInternal site initialization (DB, config, Composer)install.sh site, Dockerfile CMD
fix_permissions.shSets ownership and permissions on site files_site_init.sh, manual
Dockerfile.templateTemplate for building Docker imagesinstall.sh site (Docker)
default_Globalvars_site.phpTemplate for site configuration_site_init.sh
default_virtualhost.confTemplate for Apache VirtualHost_site_init.sh
_site_init.sh is internal — don't invoke it directly. Use install.sh site.

Sysadmin tools

Located in maintenance_scripts/sysadmin_tools/:

ScriptPurpose
manage_domain.shDomain management: set, clear, status, rollback, remove-ssl
backup_database.shBackup PostgreSQL database
restore_database.shRestore PostgreSQL database
backup_project.shFull site backup (files + database)
restore_project.shFull site restore
copy_database.shCopy database between sites
remove_account.shRemove a site completely

Reverse proxy for production (multiple Docker sites on 80/443)

For multiple Docker sites sharing standard ports, install Apache on the host:

apt-get install -y apache2
a2enmod proxy proxy_http headers ssl rewrite
systemctl restart apache2

Create /etc/apache2/sites-available/yoursite.conf:

<VirtualHost *:80>
    ServerName yoursite.com
    ServerAlias www.yoursite.com

    ProxyPreserveHost On
    ProxyRequests Off
    ProxyPass / http://127.0.0.1:8080/
    ProxyPassReverse / http://127.0.0.1:8080/

    RequestHeader set X-Real-IP %{REMOTE_ADDR}s
    RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
    RequestHeader set X-Forwarded-Proto "http"
</VirtualHost>

Enable and add SSL:

a2ensite yoursite
systemctl reload apache2
apt-get install -y certbot python3-certbot-apache
certbot --apache -d yoursite.com -d www.yoursite.com