Docker

Ready-made images on Docker Hub, multi-arch (amd64, arm64, experimental riscv64) — docker pull picks your platform. latest and <version> are built FROM scratch: nothing in the image but the static binary. The -alpine tags add a shell for docker exec debugging.

Quick start

# 1. pull the image
docker pull sqliteonlinecom/cmsnap:latest

# 2. create the example site in a volume — the admin password is printed
#    right here, in your terminal
docker run --rm -v mysite:/site sqliteonlinecom/cmsnap:latest /cmsnap def-help
#   admin login:    admin
#   admin password: ................

# 3. start the server
docker run -d --name mysite -v mysite:/site -p 80:8080 sqliteonlinecom/cmsnap:latest

Open http://<host>/ — the site is up; http://<host>/cms — sign in with the password from step 2.

Step 2 is optional. On an empty volume the container bootstraps the example site itself and prints the first-start block to stdout — admin password, the server-scope MCP token, and a one-time /cms enrollment link if the admin zone is gate-hidden. It is shown ONCE: read it with docker logs mysite.

The volume is the whole state

/site holds everything: the root settings.json, the sites with their databases, media, templates and TLS material, the node log under log/. Back it up as a folder, move it to another host, and the node comes back exactly as it was. Use a named volume so it survives container re-creation.

Run flags worth knowing

flagwhy
-v mysite:/sitethe state (see above). A bind-mounted host folder works too — then add --user "$(id -u):$(id -g)", because the image's unprivileged user (65534) cannot write a folder you own
-p 80:8080the app binds 8080 inside — a non-root user cannot take 80. Map it, or grant the binary CAP_NET_BIND_SERVICE
--network hostfor busy sites: a published port goes through docker's NAT, which costs about HALF the throughput at high request rates. With host networking the engine binds the host stack directly and -p is not needed
--restart unless-stoppedsurvives crashes and host reboots
--cpus / --memorycap the container. Caveat: the cache budgets itself from the HOST's free RAM (cgroup limits are invisible to it), so keep cache.mem_percent low enough that its budget stays under the cap
--read-onlyworks as is: everything written — databases, media, log/, the reload socket — lives under the volume

Two deployment shapes

Port published directly (-p 8080:8080): docker bridge NAT masks every external client as the gateway IP. Keep trusted_proxies empty (the bootstrap default) — with the docker subnet trusted, anyone could spoof X-Real-IP and walk around rate limits.

Behind a proxy neighbour (nginx/caddy container on the same user-defined network, app port NOT published): add the proxy’s address or the network’s subnet to trusted_proxies in the root settings.json and make the proxy overwrite X-Real-IP — the app then sees real client addresses in logs and rate limits. The upstream block with keepalive is what makes the difference: without it nginx opens a new TCP connection to the app for every request, capping throughput several times below what the engine serves.

upstream cms_up {
    server cmsnap:8080;   # the container name on the shared network
    keepalive 64;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://cms_up;
        proxy_http_version 1.1;
        proxy_pass_header  Server;
        proxy_set_header Connection        "";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Use $remote_addr, never $http_x_real_ip — the latter forwards the client’s own header, a spoofing hole. The same block for a proxy on the host is in the server article.

Notes

← All articles in this group