aptly 1.6.3
Overview
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. This image on Amazon EC2 makes it a ready-to-use repository server. It is one version of the SecureAnyCloud artifact and container registries listing on AWS Marketplace, and you pick it as the software version when you subscribe and launch.
What you get:
- TLS from the first boot. nginx serves both the published repositories (
https://<host>/) and the API (https://<host>/api/) on port 443. The certificate is generated on your instance's first boot and names 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. The password is generated on the first boot and written to/root/aptly-credentials.txt(root only), and 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. It publishes the 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 you can point an APT client 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. nginx also 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, which fixes every reachable advisory in the upstream set. The build is pinned by SHA-256 and runs 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, license 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.
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, select your Region, then choose Launch through EC2.
Choose m5.large, your key pair, and a security group as described in the prerequisites. 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}]'Connect to your instance
For aptly 1.6.3 on Ubuntu 24.04, the SSH user is ubuntu:
ssh -i <your-key.pem> ubuntu@<public-ip>Check the services
First boot generates the certificate, the signing key and the password, and publishes the demo repository. Then aptly and nginx start. Check the services:
systemctl is-active aptly nginx
sudo test -f /var/lib/sac/data/aptly-firstboot.done && echo FIRSTBOOT_DONEactive active FIRSTBOOT_DONEOnly 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' | sort0.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",...))Get your URLs and password
The credentials file is readable by root only. This command prints it with the password hidden. Run sudo cat /root/aptly-credentials.txt to show 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://secureanycloud.com/stacks/aptly-1-6-3/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.
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":"sac 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/sac-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 201Name 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: sac-aptly-demo Version: 1.0To update a published repository after adding packages, call PUT /api/publish/<prefix>/<distribution> with the same Signing block. The following commands remove 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 200The full API, including mirrors and snapshots, is described in the aptly API documentation.
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 listList of local repos: * [demo]: sac demo repository (packages: 1) To get more information about local repository, run `aptly repo show <name>`. Published repositories: * ./stable [amd64] publishes {main: [demo]: sac 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>.
Point APT at the repository
Run an APT client on the instance itself, with its own private state so the system's sources are not touched. It 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 sac-aptly-demo rm -rf $WGet: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... sac-aptly-demo: Installed: (none) Candidate: 1.0 Version table: 1.0 500 500 https://localhost stable/main amd64 PackagesOn each of your servers, copy the instance's certificate (/etc/nginx/tls/aptly.crt) to /usr/local/share/ca-certificates/aptly.crt. Compare its fingerprint with APTLY_CERT_SHA256 from Step 4, then trust it and 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/sac-aptly.gpg echo "deb [signed-by=/etc/apt/keyrings/sac-aptly.gpg] https://<your-domain>/ stable main" | sudo tee /etc/apt/sources.list.d/sac-aptly.list
sudo apt-get update && sudo apt-get install sac-aptly-demoCheck that the key's fingerprint matches APTLY_GPG_FINGERPRINT (gpg --show-keys /etc/apt/keyrings/sac-aptly.gpg). With a certificate from your own certificate authority (Step 9), you do not need the first two lines.
Check the security posture
Run the checks below to confirm the following:
- 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.
- 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/ || trueAPI, 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 -> 000000 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. 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 IPAddressDenyaptly /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.servicePasswords, 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. The password 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 ciPoint 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 nginxServer components
These are the main components and their paths:
- aptly binary: /opt/aptly/aptly
- License 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/sac-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>&1aptly 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. govulncheck.txt holds the vulnerability scan of the shipped binary.
For logs, run sudo journalctl -u aptly for aptly. Requests are logged, with the real client address, in /var/log/nginx/access.log.
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). Rotate a password if it may have been shared, because 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 license fee, and the license is in /usr/share/doc/aptly/.
The AMI price covers SecureAnyCloud's hardening, first boot setup and support. SecureAnyCloud is not affiliated with or endorsed by the aptly project.
- Email: sales@secureanycloud.com
- Website: https://secureanycloud.com
- Support: 24/7, with a 24 hour response SLA
Deploy on AWS
Launch aptly 1.6.3 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com