aptly 1.6.3 on AWS User Guide
Overview
This guide covers launching and using the aptly 1.6.3 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg artifact and container registries listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
aptly manages Debian and Ubuntu package repositories: you keep your own packages in local repositories, mirror and snapshot upstream ones, and publish any of them as a signed APT repository that apt on your servers installs from. Teams use it to ship internal software the same way the operating system ships its own, and to pin fleets to a tested snapshot of upstream packages.
Upstream aptly is a command line tool with an optional REST API that has no authentication at all, and it leaves signing keys, TLS and serving to you. The cloudimg image makes it a ready to use repository server:
- TLS from the first boot. nginx serves both the published repositories (
https://<host>/) and the API (https://<host>/api/) on port 443 with a certificate generated on your instance's first boot, naming the instance's DNS name and IP addresses. The aptly API itself listens on127.0.0.1only, and nothing listens on port 80. - An API password unique to your instance. Every API call needs HTTP Basic authentication as the user
aptly, with a password generated on the first boot and written to/root/aptly-credentials.txt(root only). nginx stores only a bcrypt hash of it. The published repositories are read only and open to anyone who can reach port 443, as an APT repository must be; nothing can be written through them. - A signing key of your own. First boot generates an RSA 3072 GPG key on your instance, used to sign every publish, and publishes its public half at
https://<host>/aptly-repo-key.asc. No key is baked into the image, so no two instances share one. - Ready to try. First boot publishes a small signed demo repository (
stable, componentmain, one package), so an APT client can be pointed at the instance straight away. - Fail closed and locked down. aptly and nginx refuse to start until first boot has generated the certificate, key and password, and nginx refuses to start with a login file that is not bcrypt. Requests for host names the instance does not serve are dropped; aptly, nginx and the first boot service are blocked from the instance metadata service, and the AMI requires IMDSv2.
- Built from source, reproducibly. aptly 1.6.3 is compiled from the upstream release with current versions of its Go dependencies (fixing every reachable advisory in the upstream set), pinned by SHA-256, on fully patched Ubuntu 24.04 LTS with unattended security upgrades switched on.
What is on the image: aptly 1.6.3 (/opt/aptly/aptly, licence and build provenance in /usr/share/doc/aptly), nginx, GnuPG, Ubuntu 24.04 LTS, 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 your servers can reach.
- A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 from the servers that install packages and the machines that upload them. Nothing else needs to be open.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick aptly 1.6.3 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. Packages live on the root volume under /var/lib/aptly, so size the root volume for the repositories you plan to keep.
With the AWS CLI (replace the values in angle brackets):
aws ec2 run-instances --region <region> --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=aptly}]'
Step 2: Connect to your instance
| Version | SSH user |
|---|---|
| aptly 1.6.3 on Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
First boot generates the certificate, the signing key and the password and publishes the demo repository, then aptly and nginx start:
systemctl is-active aptly nginx
sudo test -f /var/lib/cloudimg/aptly-firstboot.done && echo FIRSTBOOT_DONE
active
active
FIRSTBOOT_DONE
Only SSH and nginx listen on the network. The aptly API is on loopback behind nginx, and the DNS resolver listens on loopback only:
sudo ss -lntp | awk 'NR>1 {print $4, $6}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort
0.0.0.0:22 users:(("sshd",...),("systemd",...))
0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...))
127.0.0.1:8080 users:(("aptly",...))
127.0.0.53%lo:53 users:(("systemd-resolve",...))
127.0.0.54:53 users:(("systemd-resolve",...))
[::]:22 users:(("sshd",...),("systemd",...))
[::]:443 users:(("nginx",...),("nginx",...),("nginx",...))
Step 4: Get your URLs and password
The credentials file is readable by root only. This prints it with the password hidden; sudo cat /root/aptly-credentials.txt shows the password itself:
sudo sed -E 's/^(APTLY_API_PASSWORD=).*/\1<hidden>/' /root/aptly-credentials.txt
# aptly 1.6.3 - generated on first boot by aptly-firstboot.service
# This password and the repository signing key are unique to this instance; none was baked into the image.
# nginx keeps only a bcrypt hash of the password in /etc/nginx/aptly.htpasswd. Keep this file secret.
APTLY_API_URL=https://ec2-3-86-203-109.compute-1.amazonaws.com/api/
APTLY_API_USER=aptly
APTLY_API_PASSWORD=<hidden>
APTLY_REPO_URL=https://ec2-3-86-203-109.compute-1.amazonaws.com/
APTLY_REPO_DISTRIBUTION=stable
APTLY_REPO_COMPONENT=main
APTLY_REPO_PUBKEY_URL=https://ec2-3-86-203-109.compute-1.amazonaws.com/aptly-repo-key.asc
APTLY_GPG_FINGERPRINT=15993EFE853DA423E3210797B98CE6F374605D78
APTLY_CA_CERT=/etc/nginx/tls/aptly.crt
APTLY_CERT_SHA256=8F:7C:40:EF:E3:92:7C:9C:10:AA:FB:3F:E4:54:F4:12:E5:C7:C8:A0:9D:FF:9F:FB:B0:31:0C:1D:2F:0B:EF:CD
#
# API: curl --cacert aptly.crt -u aptly:<APTLY_API_PASSWORD> https://ec2-3-86-203-109.compute-1.amazonaws.com/api/version
# Local: sudo -u aptly aptly repo list (the API runs with -no-lock)
# Change the API password: sudo aptly-set-password
# Add an API user: sudo aptly-set-password --user <name>
# Change the name it serves as: sudo aptly-set-hostname <your-dns-name>
# User guide: https://www.cloudimg.co.uk/guides/aptly-1-6-3-on-ubuntu-24-04-aws/
Your instance shows its own DNS name, fingerprint and certificate hash. The URLs use the instance's public DNS name; to serve under your own name, see Step 9.
Step 5: Use the API
The examples run on the instance and hand the password to curl on standard input, so it never appears in the process list. The API reports its version, the local repositories and what is published:
sudo bash -c '
P=$(grep "^APTLY_API_PASSWORD=" /root/aptly-credentials.txt | cut -d= -f2-)
api() { printf "user = \"aptly:%s\"\n" "$P" | curl -K - -s --cacert /etc/nginx/tls/aptly.crt "$@"; echo; }
api https://127.0.0.1/api/version
api https://127.0.0.1/api/repos'
{"Version":"1.6.3"}
[{"Name":"demo","Comment":"cloudimg demo repository","DefaultDistribution":"stable","DefaultComponent":"main","NumPackages":1}]
Publishing your own packages takes four calls: create a local repository, upload a .deb, add it to the repository, and publish the repository signed with your instance's key. This example publishes the demo package again as a repository called internal, under the prefix internal and the distribution noble:
sudo bash -c '
P=$(grep "^APTLY_API_PASSWORD=" /root/aptly-credentials.txt | cut -d= -f2-)
FPR=$(grep "^APTLY_GPG_FINGERPRINT=" /root/aptly-credentials.txt | cut -d= -f2-)
api() { printf "user = \"aptly:%s\"\n" "$P" | curl -K - -s --cacert /etc/nginx/tls/aptly.crt -w " -> HTTP %{http_code}\n" -o /dev/null "$@"; }
J="Content-Type: application/json"
api -X POST -H "$J" -d "{\"Name\":\"internal\",\"Comment\":\"our packages\",\"DefaultDistribution\":\"noble\",\"DefaultComponent\":\"main\"}" https://127.0.0.1/api/repos
api -X POST -F file=@/opt/aptly/demo/cloudimg-aptly-demo_1.0_all.deb https://127.0.0.1/api/files/upload-1
api -X POST https://127.0.0.1/api/repos/internal/file/upload-1
api -X POST -H "$J" -d "{\"SourceKind\":\"local\",\"Sources\":[{\"Name\":\"internal\"}],\"Distribution\":\"noble\",\"Architectures\":[\"amd64\"],\"Signing\":{\"GpgKey\":\"$FPR\"}}" https://127.0.0.1/api/publish/internal'
-> HTTP 201
-> HTTP 200
-> HTTP 200
-> HTTP 201
Name the architectures your servers use in Architectures (aptly needs them when a repository holds only all packages). The repository is now served, signed, at https://<host>/internal/:
curl -s --cacert /etc/nginx/tls/aptly.crt https://localhost/internal/dists/noble/Release | grep -E '^(Suite|Architectures|Components):'
curl -s --cacert /etc/nginx/tls/aptly.crt https://localhost/internal/dists/noble/main/binary-amd64/Packages | grep -E '^(Package|Version):'
Suite: noble
Architectures: amd64
Components: main
Package: cloudimg-aptly-demo
Version: 1.0
To update a published repository after adding packages, call PUT /api/publish/<prefix>/<distribution> with the same Signing block. This removes the example again:
sudo bash -c '
P=$(grep "^APTLY_API_PASSWORD=" /root/aptly-credentials.txt | cut -d= -f2-)
api() { printf "user = \"aptly:%s\"\n" "$P" | curl -K - -s --cacert /etc/nginx/tls/aptly.crt -w " -> HTTP %{http_code}\n" -o /dev/null "$@"; }
api -X DELETE https://127.0.0.1/api/publish/internal/noble
api -X DELETE https://127.0.0.1/api/repos/internal'
-> HTTP 200
-> HTTP 200
The full API, including mirrors and snapshots, is described in the aptly API documentation.
Step 6: The aptly command line
The aptly command works on the instance as the aptly user while the API is running (the API runs with -no-lock, so both can use the database):
sudo -u aptly /opt/aptly/aptly -config=/etc/aptly.conf repo list
sudo -u aptly /opt/aptly/aptly -config=/etc/aptly.conf publish list
List of local repos:
* [demo]: cloudimg demo repository (packages: 1)
To get more information about local repository, run `aptly repo show <name>`.
Published repositories:
* ./stable [amd64] publishes {main: [demo]: cloudimg demo repository}
The signing key is in the aptly user's keyring (/var/lib/aptly/.gnupg), so command line publishes sign with it too: sudo -u aptly /opt/aptly/aptly -config=/etc/aptly.conf publish repo -gpg-key=<APTLY_GPG_FINGERPRINT> <repo>.
Step 7: Point APT at the repository
An APT client on the instance itself, with its own private state so the system's sources are not touched, checks the whole chain: HTTPS with the instance's certificate, the signature with the published key, and the package download:
W=$(mktemp -d); mkdir -p $W/lists/partial $W/archives/partial
curl -s --cacert /etc/nginx/tls/aptly.crt https://localhost/aptly-repo-key.asc | gpg --dearmor > $W/aptly-repo.gpg
echo "deb [signed-by=$W/aptly-repo.gpg] https://localhost/ stable main" > $W/aptly.list
O="-o Dir::State::Lists=$W/lists -o Dir::Cache::Archives=$W/archives -o Dir::Cache=$W -o Dir::Etc::SourceList=$W/aptly.list -o Dir::Etc::SourceParts=- -o Acquire::https::CaInfo=/etc/nginx/tls/aptly.crt -o Debug::NoLocking=1"
apt-get $O update
apt-cache $O policy cloudimg-aptly-demo
rm -rf $W
Get:1 https://localhost stable InRelease [4131 B]
Get:2 https://localhost stable/main amd64 Packages [576 B]
Fetched 4707 B in 0s (37.5 kB/s)
Reading package lists...
cloudimg-aptly-demo:
Installed: (none)
Candidate: 1.0
Version table:
1.0 500
500 https://localhost stable/main amd64 Packages
On each of your servers, copy the instance's certificate (/etc/nginx/tls/aptly.crt) to /usr/local/share/ca-certificates/aptly.crt and compare its fingerprint with APTLY_CERT_SHA256 from Step 4, then trust it, add the signing key and the source. Run this on your client, with your instance's DNS name (or your own, Step 9) in place of <your-domain>:
openssl x509 -noout -fingerprint -sha256 -in /usr/local/share/ca-certificates/aptly.crt
sudo update-ca-certificates
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://<your-domain>/aptly-repo-key.asc | sudo gpg --dearmor -o /etc/apt/keyrings/cloudimg-aptly.gpg
echo "deb [signed-by=/etc/apt/keyrings/cloudimg-aptly.gpg] https://<your-domain>/ stable main" | sudo tee /etc/apt/sources.list.d/cloudimg-aptly.list
sudo apt-get update && sudo apt-get install cloudimg-aptly-demo
Check that the key's fingerprint matches APTLY_GPG_FINGERPRINT (gpg --show-keys /etc/apt/keyrings/cloudimg-aptly.gpg). With a certificate from your own certificate authority (Step 9), the first two lines are not needed.
Step 8: Check the security posture
API calls without the password, or with a wrong one, are refused; the published repository is readable and cannot be written; the health check stays open for load balancers; a request for a host name the instance does not serve is dropped without an answer, and nothing answers on port 80:
C=/etc/nginx/tls/aptly.crt
curl -s --cacert $C -o /dev/null -w 'API, no login -> HTTP %{http_code}\n' https://localhost/api/version
curl -s --cacert $C -K <(printf 'user = "aptly:not-the-password"\n') -o /dev/null -w 'API, wrong password -> HTTP %{http_code}\n' https://localhost/api/version
curl -s --cacert $C -o /dev/null -w 'repository index -> HTTP %{http_code}\n' https://localhost/dists/stable/InRelease
curl -s --cacert $C -X PUT -d x -o /dev/null -w 'write to the index -> HTTP %{http_code}\n' https://localhost/dists/stable/InRelease
curl -s --cacert $C -o /dev/null -w 'health check -> HTTP %{http_code}\n' https://localhost/healthz
curl -s --cacert $C -H 'Host: evil.example' -o /dev/null -w 'unknown host -> %{http_code}\n' https://localhost/healthz || true
curl -s -o /dev/null -w 'plain http :80 -> %{http_code}\n' --connect-timeout 3 http://127.0.0.1/ || true
API, no login -> HTTP 401
API, wrong password -> HTTP 401
repository index -> HTTP 200
write to the index -> HTTP 405
health check -> HTTP 200
unknown host -> 000
plain http :80 -> 000
000 means no answer: nginx closes the connection for a host name it does not serve, and nothing listens on port 80.
aptly runs as the unprivileged aptly user with its API on loopback, and aptly, nginx and the first boot service cannot reach the instance metadata service:
ps -o user:8=,args= -C aptly
systemctl show aptly nginx aptly-firstboot -p Id -p IPAddressDeny
aptly /opt/aptly/aptly -config=/etc/aptly.conf api serve -listen=127.0.0.1:8080 -no-lock
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Id=aptly.service
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Id=nginx.service
IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32
Id=aptly-firstboot.service
Step 9: Passwords, users, your own DNS name
aptly-set-password manages the API login file. Run sudo aptly-set-password with no arguments to generate a new random password for aptly; it is written to /root/aptly-credentials.txt. To choose the password yourself, hand it over on standard input (printf is a shell builtin, so the password never appears in the process list; start the line with a space so most shells keep it out of your history). Give each team or CI system its own user with --user <name>, and remove one with --delete <name>. Every API user has full access to the API. Passwords are 12 to 128 printable characters without spaces or :. nginx reads the login file on every request, so nothing needs restarting:
printf '%s\n' '<new-password>' | sudo aptly-set-password --user ci --stdin
sudo aptly-set-password --delete ci
Point a DNS record at the instance, then set the name the instance is served as. This mints a new self signed certificate for the name (and the instance's IP addresses), updates nginx and the URLs in the credentials file, and keeps the name across reboots. Give your servers the new certificate afterwards and use the name in their sources:
sudo aptly-set-hostname <your-domain>
To use a certificate from your own certificate authority instead, copy it over the generated pair and reload nginx (running aptly-set-hostname again replaces it with a self signed one):
sudo install -m 0644 <your-domain>.crt /etc/nginx/tls/aptly.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/tls/aptly.key
sudo nginx -t && sudo systemctl reload nginx
Step 10: Server components
| Component | Path |
|---|---|
| aptly binary | /opt/aptly/aptly |
| Licence and build provenance | /usr/share/doc/aptly/ |
| aptly configuration | /etc/aptly.conf |
| Database, package pool, published tree | /var/lib/aptly/ (published files in /var/lib/aptly/public) |
| Signing keyring | /var/lib/aptly/.gnupg (owner aptly, mode 0700) |
| API login file (bcrypt) | /etc/nginx/aptly.htpasswd |
| Service unit | /etc/systemd/system/aptly.service (loopback only) |
| nginx site | /etc/nginx/sites-available/cloudimg-aptly |
| Certificate and key | /etc/nginx/tls/ |
| First boot script | /usr/local/sbin/aptly-firstboot.sh |
| Helpers | /usr/local/sbin/aptly-set-password, aptly-set-hostname, aptly-auth-guard |
| Credentials | /root/aptly-credentials.txt (mode 0600) |
/opt/aptly/aptly version
sha256sum /opt/aptly/aptly
nginx -v 2>&1
aptly version: 1.6.3
c1fcc430a44dd49d91af76a86e33c6d62ac840762e0513200a2f1136a1ca0d05 /opt/aptly/aptly
nginx version: nginx/1.24.0 (Ubuntu)
/usr/share/doc/aptly/BUILD-PROVENANCE.txt records the source release, the Go toolchain and the dependency versions the binary was built with, and govulncheck.txt the vulnerability scan of the shipped binary. Logs: sudo journalctl -u aptly for aptly, /var/log/nginx/access.log for requests (with the real client address).
Step 11: Security recommendations
- Allow port 22 in the security group from your own networks only, and port 443 only from the networks of your servers and of the machines that upload packages.
- Give each team or CI system its own API user (Step 9) and rotate a password if it may have been shared: anyone with an API password can publish packages that your servers will trust.
- Back up
/var/lib/aptly, including the signing keyring in/var/lib/aptly/.gnupg; your servers trust that key, so keep the backup private. - Use your own DNS name and certificate (Step 9).
- Keep the operating system patched; unattended security upgrades are on, and
sudo apt-get update && sudo apt-get upgradeapplies everything else.
Support and licensing
aptly is open source under the MIT License; there is no per user or per CPU licence fee. The licence is in /usr/share/doc/aptly/. cloudimg is not affiliated with or endorsed by the aptly project. The AMI price covers cloudimg's hardening, first boot setup and support.
- Email: support@cloudimg.co.uk
- Website: https://www.cloudimg.co.uk
- Support: 24/7, with a 24 hour response SLA