E1
Security AWS

Enclosed 1.16.1 on AWS User Guide

| Product: Enclosed 1.16.1 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Enclosed 1.16.1 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg identity, secrets and PKI listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

Enclosed is an open source service for sending private notes and files. You write a note (a password, an API key, a document), optionally attach files, choose when it expires, whether it is deleted after the first reading and whether it needs an extra password, and send the link. The note is encrypted in your browser (AES-256-GCM) with a key generated on your device. The key is placed in the part of the link after the #, which browsers never send to a server, so the instance only ever receives and stores ciphertext.

The cloudimg image is a hardened, ready-to-use Enclosed server:

  • The official Enclosed container, pinned. The image runs the upstream corentinth/enclosed:1.16.1 container image unmodified, pinned by its digest and stored on the AMI, so a new instance never downloads it. Docker CE comes from Docker's own repository with its signing key pinned by fingerprint; the Docker daemon listens only on its local socket.
  • nginx in front, Enclosed behind it. nginx is the only public listener (ports 80 and 443). The Enclosed container is published on 127.0.0.1:8787, the loopback address only.
  • HTTPS from the first boot. Browsers only allow the Web Crypto functions Enclosed needs on a secure (HTTPS) page, so the image serves HTTPS from the first boot with a certificate generated on your instance (an end-entity certificate, not a certificate authority). Port 80 redirects to HTTPS. The instance's name is set from its own EC2 public DNS name at first boot; you can change it to your own domain.
  • A private instance with a per-instance login. Creating a note needs a login. First boot creates one account, admin@enclosed.local, with a random password unique to your instance, and a random JWT signing secret for the login sessions. Anyone you send a link to can open that note without an account: the key is in the link. The upstream default signing secret (change-me) can never be used: a start-up check refuses to start Enclosed with it, or with any missing, short or misplaced secret.
  • Secrets kept out of the container configuration. The signing secret and the account list are in a file only root can reach, mounted read-only into the container and read by Node.js itself, so they are not shown by docker inspect, on any command line, in any systemd unit or in a process environment.
  • A locked-down container. The container runs as a dedicated non-root user with a read-only root filesystem, no Linux capabilities, no-new-privileges, memory and process limits, and no core dumps.
  • Abuse limits. nginx allows 10 login attempts per minute per client address (burst 5), 30 new notes per minute (burst 20) and 20 API requests per second; the client address is the real TCP peer, never a header a client can forge. Every note must expire (10 minutes to 30 days, default 1 hour), a note is at most 50 MB once encrypted, and a page on another website cannot log in or create notes here.
  • Browser hardening. A Content-Security-Policy allows only Enclosed's own scripts (the one inline configuration script is pinned by its hash), refuses eval and third-party resources, and refuses framing; the server sends no referrer. The nginx access log records /api/notes/:id instead of note ids, and Enclosed's own request log is kept out of the system journal.
  • Locked down. TLS 1.2 and 1.3 only; the container, Docker, containerd, nginx, Enclosed and the first-boot service are all blocked from the instance metadata service, and the AMI requires IMDSv2.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on (for Ubuntu and for Docker CE).

What is on the image: the Enclosed 1.16.1 container image (Node.js 22.23.4 on Alpine Linux 3.24.3), Docker CE 29.9.2, nginx from Ubuntu 24.04, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet.
  • A security group that allows TCP 22 (SSH) from your own networks only, TCP 443 (the web app) and TCP 80 (redirects to HTTPS). Recipients of a note link need to reach port 443 to open it, so open 443 to wherever your recipients are; note creation always needs the admin login. Do not open 8787: the container listens on the loopback address only.
  • Notes and attachments are stored (encrypted) on the root volume. The default root volume leaves about 3.5 GB free; choose a larger root volume at launch if you expect to keep many large attachments.
  • For production, a DNS name you control (for example notes.example.com) and, ideally, a certificate from a certificate authority. Until then the instance uses its EC2 public DNS name with its own self-signed certificate.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Enclosed 1.16.1 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair, and a security group as above.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --image-id <ami-id-from-the-listing> --instance-type m5.large \
  --key-name <your-key-pair> --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=enclosed}]'

First boot takes about half a minute after the instance is running. If you want the instance to use your own name from the start, put it in /etc/cloudimg/enclosed-domain with user data (for example a cloud-init write_files entry); otherwise first boot uses the instance's public DNS name, or its public IP address if it has no DNS name.

Step 2: Connect to your instance

Operating system SSH user
Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

systemctl is-active enclosed-firstboot docker enclosed nginx
active
active
active
active

Only SSH, HTTP and HTTPS are public. The Enclosed container is published on the loopback address:

sudo ss -tln | grep -E ':(22|80|443|8787) '
LISTEN 0      4096         0.0.0.0:22        0.0.0.0:*
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*
LISTEN 0      511          0.0.0.0:443       0.0.0.0:*
LISTEN 0      4096       127.0.0.1:8787      0.0.0.0:*
LISTEN 0      4096            [::]:22           [::]:*
LISTEN 0      511             [::]:80           [::]:*
LISTEN 0      511             [::]:443          [::]:*

A local health check (answered only on the loopback address):

curl -s http://127.0.0.1/api/ping; echo
{"status":"ok"}

Step 4: Read the address, the admin login and the certificate fingerprint

sudo cat /root/enclosed-credentials.txt

The file (root only, mode 0600) gives this instance's address, the admin e-mail and password, and the SHA-256 fingerprint of its certificate:

# Enclosed 1.16.1 - written on first boot by enclosed-firstboot.service. Unique to this instance.
ENCLOSED_URL=https://ec2-32-201-68-126.compute-1.amazonaws.com/
ENCLOSED_ADMIN_EMAIL=admin@enclosed.local
ENCLOSED_ADMIN_PASSWORD=<per-instance password>
ENCLOSED_TLS_SHA256=6F:F0:1F:70:...:(64 hex digits in pairs)

# Log in at ENCLOSED_URL with the e-mail and password above to CREATE notes. Anyone you send a note link to can
# open it WITHOUT an account: the decryption key travels in the link's #fragment, which never reaches this server.
...

The certificate is self-signed and unique to your instance, so your browser warns about it. Open the certificate details in the browser, check that its SHA-256 fingerprint matches ENCLOSED_TLS_SHA256, and only then continue. Do not add the certificate to your operating system's trust store; for production, replace it with a certificate from a certificate authority (Step 9). The certificate is an end-entity certificate for this instance's names only:

openssl x509 -in /etc/ssl/enclosed/enclosed.crt -noout -subject -ext basicConstraints,keyUsage,extendedKeyUsage,subjectAltName
subject=CN = ec2-32-201-68-126.compute-1.amazonaws.com, O = cloudimg
X509v3 Subject Alternative Name: 
    DNS:ec2-32-201-68-126.compute-1.amazonaws.com, IP Address:32.201.68.126, IP Address:172.31.94.230
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Key Usage: critical
    Digital Signature, Key Encipherment
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

Step 5: Send a note

Browse to the ENCLOSED_URL address. A visitor who is not logged in is sent to the login page: on this private instance only an account can create notes. Log in with ENCLOSED_ADMIN_EMAIL and ENCLOSED_ADMIN_PASSWORD:

The Enclosed login page with the admin e-mail address entered

Write the note. You can add a note password (the recipient must type it; it is not part of the link), choose the expiration delay, switch on delete after reading and attach files:

A note being written: the text, a note password, the 1 hour expiry, delete after reading switched on and an attached file

Choose Create note. Your browser encrypts the note and its files and uploads only the ciphertext; the page then shows the share link (also as a QR code). Send the link to the recipient over any channel, and the note password over a different one:

Note created successfully: the share link with the key after the #, the copy button and the QR code

Step 6: Open a note (no account needed)

The recipient opens the link in any browser, without logging in. A note that is deleted after reading says so first, and is only fetched when the recipient chooses to see it:

The warning that the note will be deleted after reading, with the I understand, show me the note button

A password-protected note then asks for its password. A wrong password does not decrypt it:

The note password prompt with the Unlock note button

With the right password the browser decrypts the note and its attachments:

The decrypted note: the note text with a copy button and the attached file with a download button

The note is now gone from the server. Opening the same link again shows that it was not found:

Opening the link a second time: note not found

A note that is not deleted after reading can be opened until it expires; expired notes are refused and deleted.

Step 7: Use the API

The web app talks to a small JSON API, which you can also call from scripts (the Enclosed command-line client, npx @enclosed/cli, encrypts for you). The server never sees a key: anything you send as payload is stored as you send it, so encrypt it first. Creating a note needs a login token; reading a public note does not. On the instance itself, with the instance's own certificate as the trust anchor, and with the password and token kept in a private temporary directory rather than on a command line:

U=$(sudo sed -n 's/^ENCLOSED_URL=//p' /root/enclosed-credentials.txt)
CA=/etc/ssl/enclosed/enclosed.crt
NOTE='{"payload":"already-encrypted-text","deleteAfterReading":true,"ttlInSeconds":3600,"encryptionAlgorithm":"aes-256-gcm","serializationFormat":"cbor-array"}'
curl -s --cacert "$CA" -o /dev/null -w 'anonymous create: %{http_code}\n' -H 'Content-Type: application/json' --data "$NOTE" "${U}api/notes"
umask 077; T=$(mktemp -d)
sudo sed -n 's/^ENCLOSED_ADMIN_PASSWORD=//p' /root/enclosed-credentials.txt | python3 -c 'import json,sys; print(json.dumps({"email": "admin@enclosed.local", "password": sys.stdin.read().strip()}))' > "$T/login.json"
curl -s --cacert "$CA" -H 'Content-Type: application/json' --data @"$T/login.json" "${U}api/auth/login" | python3 -c 'import json,sys; print("Authorization: Bearer " + json.load(sys.stdin)["accessToken"])' > "$T/auth"
ID=$(curl -s --cacert "$CA" -H @"$T/auth" -H 'Content-Type: application/json' --data "$NOTE" "${U}api/notes" | python3 -c 'import json,sys; print(json.load(sys.stdin)["noteId"])')
echo "created a note with a ${#ID}-character id"
curl -s --cacert "$CA" -o /dev/null -w 'first read: %{http_code}\n' "${U}api/notes/${ID}"
curl -s --cacert "$CA" -o /dev/null -w 'second read: %{http_code}\n' "${U}api/notes/${ID}"
rm -rf "$T"
anonymous create: 401
created a note with a 26-character id
first read: 200
second read: 404

A request that a page on another website makes (its Origin is not this instance) is refused, and so is any cross-origin preflight:

U=$(sudo sed -n 's/^ENCLOSED_URL=//p' /root/enclosed-credentials.txt)
curl -s --cacert /etc/ssl/enclosed/enclosed.crt -o /dev/null -w 'cross-site create: %{http_code}\n' -H 'Origin: https://evil.example' -H 'Content-Type: application/json' --data '{}' "${U}api/notes"
curl -s --cacert /etc/ssl/enclosed/enclosed.crt -o /dev/null -w 'preflight: %{http_code}\n' -X OPTIONS -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' "${U}api/notes"
cross-site create: 403
preflight: 403

Step 8: The admin account and its secrets

The admin password and the JWT signing secret are generated on each instance's first boot. To check that the login works (and that a wrong password does not):

sudo enclosed-admin check
ENCLOSED_ADMIN_OK login=200 wrong=401

To get a new admin password and a new signing secret (every logged-in session ends; links you already sent keep working), run sudo enclosed-admin rotate; the new password is written to /root/enclosed-credentials.txt. The secrets live in a file only root can reach; the container reads it through a read-only mount:

sudo stat -c '%a %U:%G %n' /etc/enclosed/secrets /etc/enclosed/secrets/enclosed.env
700 root:root /etc/enclosed/secrets
600 enclosed:enclosed /etc/enclosed/secrets/enclosed.env

The container's own configuration holds only the non-secret settings:

sudo docker inspect enclosed --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -E '^(PUBLIC_IS_AUTHENTICATION_REQUIRED|NOTES_MAX|AUTHENTICATION_)'
PUBLIC_IS_AUTHENTICATION_REQUIRED=true
NOTES_MAX_ENCRYPTED_PAYLOAD_LENGTH=52428800
AUTHENTICATION_JWT_DURATION_SECONDS=86400

Step 9: Use your own domain and certificate

Point a DNS record (for example notes.example.com) at the instance, then:

sudo enclosed-set-domain notes.example.com

nginx then serves that name (the instance's EC2 name and addresses keep working) and, if the certificate is still the cloudimg self-signed one, a new self-signed certificate is issued for the new name. Share links use the name the sender browsed to. To use a certificate from a certificate authority, replace /etc/ssl/enclosed/enclosed.crt (full chain) and /etc/ssl/enclosed/enclosed.key (mode 0600, root) and run sudo systemctl reload nginx; enclosed-set-domain never overwrites a certificate that is not cloudimg's own. Check the current name and fingerprint with sudo enclosed-set-domain --show.

Step 10: Settings, limits and storage

The non-secret settings are in /etc/enclosed/enclosed.env:

grep -E '^(PUBLIC_IS_|PUBLIC_DEFAULT_NOTE_TTL|NOTES_MAX|AUTHENTICATION_JWT_DURATION)' /etc/enclosed/enclosed.env
PUBLIC_IS_AUTHENTICATION_REQUIRED=true
PUBLIC_IS_SETTING_NO_EXPIRATION_ALLOWED=false
PUBLIC_DEFAULT_NOTE_TTL_SECONDS=3600
NOTES_MAX_ENCRYPTED_PAYLOAD_LENGTH=52428800
AUTHENTICATION_JWT_DURATION_SECONDS=86400

After editing the file, run sudo systemctl restart enclosed. This appliance is a private instance: the start-up check refuses to start Enclosed with PUBLIC_IS_AUTHENTICATION_REQUIRED set to anything but true, and refuses any secret in this file (it is visible to docker inspect). PUBLIC_IS_SETTING_NO_EXPIRATION_ALLOWED=false means every note expires. If you raise NOTES_MAX_ENCRYPTED_PAYLOAD_LENGTH above 50 MB, also raise client_max_body_size (55m, in the location = /api/notes block) in /usr/local/lib/cloudimg/enclosed/nginx-site.tmpl, then run sudo enclosed-set-domain --boot and sudo systemctl reload nginx.

nginx limits how fast one client address can log in, create notes and call the API. The address is the real TCP peer; a forged X-Forwarded-For header neither changes it nor appears in the log:

sudo grep -E 'limit_req_zone' /etc/nginx/conf.d/cloudimg-enclosed-maps.conf
limit_req_zone $enc_post_key zone=enc_login:10m rate=10r/m;
limit_req_zone $enc_post_key zone=enc_create:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=enc_api:10m rate=20r/s;

A client over a limit gets HTTP 429 until its allowance refills. The access log records the note API without note ids:

sudo tail -2 /var/log/nginx/access.log | sed -E 's/^[0-9.]+ /<client-ip> /'
<client-ip> - - [30/Oct/2026:11:37:33 +0000] "POST /api/notes HTTP/2.0" 403 146 "curl/8.5.0"
<client-ip> - - [30/Oct/2026:11:37:33 +0000] "OPTIONS /api/notes HTTP/2.0" 403 146 "curl/8.5.0"

Storage. Notes (ciphertext only) are files in /var/lib/enclosed/data, owned by the container's user. Expired notes are deleted every hour and whenever someone asks for one. To see how many notes are stored:

sudo enclosed-storage count

To delete every stored note, stop Enclosed and purge the store: sudo systemctl stop enclosed && sudo enclosed-storage purge && sudo systemctl start enclosed. A backup of /var/lib/enclosed/data holds only ciphertext; without the links (which hold the keys) it cannot be read.

Step 11: Security notes

  • The container. It runs the pinned upstream image as a non-root user, with a read-only root filesystem, no capabilities and no-new-privileges, published on the loopback address only:
sudo docker inspect enclosed --format 'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} capdrop={{.HostConfig.CapDrop}} privileged={{.HostConfig.Privileged}} securityopt={{.HostConfig.SecurityOpt}} ports={{json .HostConfig.PortBindings}}'
user=997:986 readonly=true capdrop=[ALL] privileged=false securityopt=[no-new-privileges:true] ports={"8787/tcp":[{"HostIp":"127.0.0.1","HostPort":"8787"}]}
  • Metadata service. The AMI requires IMDSv2. Containers are denied the metadata addresses by firewall rules that Docker loads before any container starts, and Docker, containerd, Enclosed, nginx and the first-boot service are denied them by systemd. Enclosed makes no outbound requests.
sudo iptables -S DOCKER-USER
systemctl show -p IPAddressDeny enclosed docker | grep -c 169.254.169.254
-N DOCKER-USER
-A DOCKER-USER -d 168.63.129.16/32 -j DROP
-A DOCKER-USER -d 169.254.169.254/32 -j DROP
2
  • Response headers. Every page is served with a Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and HSTS; API responses are not cacheable. The policy blocks the web fonts Enclosed would otherwise load from a third-party font service, so a recipient's browser never contacts a third party (pages use the browser's own fonts), and it blocks eval (Enclosed's decoder then uses its built-in fallback).
  • Advisories. The container image's operating system packages have no known vulnerabilities at build time. The Node.js image it is built on also ships the npm command-line tool, whose bundled modules have published advisories; npm is never run on this image (the container runs node directly, as a non-root user with a read-only filesystem). One advisory in Enclosed's web framework (Hono CVE-2026-69207, rated medium: slow parsing of a crafted Access-Control-Request-Headers header) is closed on this image because nginx answers every cross-origin preflight itself and never forwards that header to Enclosed.
  • Logs. Enclosed logs each request path (note ids, never keys) to its container log, which only root can read (sudo docker logs enclosed) and which is removed whenever the container restarts; it is kept out of the system journal.
  • Upgrades. Ubuntu security updates (including nginx) and Docker CE updates install automatically. The Enclosed container is pinned by digest and never updated in place; cloudimg publishes new versions of this AMI for new Enclosed releases.

Step 12: Licences and source

Enclosed is licensed under the Apache License 2.0. The image ships its licence and the exact source and image references:

cat /usr/share/doc/cloudimg-enclosed/SOURCE.txt
head -3 /usr/share/doc/cloudimg-enclosed/LICENSE-enclosed-Apache-2.0.txt
cloudimg Enclosed 1.16.1 appliance - source

Enclosed (Apache License 2.0, LICENSE-enclosed-Apache-2.0.txt; Copyright the Enclosed authors, Corentin Thomasset):
  https://github.com/CorentinTh/enclosed/tree/v1.16.1  (commit cb6db13bd486622d48c0d880ecb7bd08023f1f30)
This image runs the OFFICIAL upstream container image UNMODIFIED:
  corentinth/enclosed:1.16.1
    OCI index     sha256:e65f73b085823ceeab1a39c515614c1a24e9b5c69b8e1221d3d7071a4f8bc4a8
    linux/amd64   sha256:fb6547f358c3230e616be3b35f307a0847da55e6ba7ec3d9dc50eeedee993ec5  (the reference this instance runs)
    image config  sha256:275fda1429f7def403ebfecb3c3f58564fb38a786d2e4de748a0302f1ddc7d65
  (Node.js 22.23.4 on Alpine Linux 3.24.3; the image's own notices are inside it.)
cloudimg adds deployment configuration only: systemd units, an nginx HTTPS reverse proxy, per-instance secrets
and container hardening (see /etc/systemd/system/enclosed*.service, /etc/nginx, /etc/enclosed).
Docker CE is installed from Docker's official repository (download.docker.com), key pinned by fingerprint.
Enclosed is a trademark of its respective owner; cloudimg is not affiliated with or endorsed by the Enclosed project.
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

All product and company names are trademarks or registered trademarks of their respective holders; cloudimg is not affiliated with or endorsed by them.

Support

cloudimg supports this image: email support@cloudimg.co.uk. See the identity, secrets and PKI listing guide for the other versions in this listing.