diff --git a/actions.go b/actions.go
index ff9dba47717cd59011f228450c86b0aaa40f54cb..3d63fd517833bc5b92142b22f39f5850871e0df4 100644
--- a/actions.go
+++ b/actions.go
@@ -695,10 +695,11 @@ type CreateResourcesResponse struct {
 }
 
 // ApplyTemplate fills in default values for the resources in the request.
-func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
+func (req *CreateResourcesRequest) ApplyTemplate(ctx context.Context, _ TX, s *AccountService, user *User) error {
 	for _, r := range req.Resources {
 		s.resourceTemplates.applyTemplate(ctx, r, user)
 	}
+	return nil
 }
 
 // ValidationUser returns the user to be used for validation purposes.
@@ -782,17 +783,33 @@ type CreateUserRequest struct {
 }
 
 // ApplyTemplate fills in default values for the resources in the request.
-func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, s *AccountService, user *User) {
+func (req *CreateUserRequest) ApplyTemplate(ctx context.Context, tx TX, s *AccountService, _ *User) error {
+	// Some fields should be unset because there are specific
+	// methods to modify those attributes.
+	req.User.Has2FA = false
+	req.User.HasEncryptionKeys = false
+	req.User.PasswordRecoveryHint = ""
+	req.User.AppSpecificPasswords = nil
+	if req.User.Lang == "" {
+		req.User.Lang = "en"
+	}
+
+	// Allocate a new user ID.
+	uid, err := tx.NextUID(ctx)
+	if err != nil {
+		return err
+	}
+	req.User.UID = uid
+
+	// Apply templates to all resources in the request.
 	for _, r := range req.User.Resources {
-		s.resourceTemplates.applyTemplate(ctx, r, user)
+		s.resourceTemplates.applyTemplate(ctx, r, req.User)
 	}
+	return nil
 }
 
 // Validate the request.
 func (req *CreateUserRequest) Validate(ctx context.Context, s *AccountService, _ *User) error {
-	// Override server-generated values.
-	fillUserTemplate(req.User)
-
 	// Validate the user *and* all resources.
 	if err := s.userValidator(ctx, req.User); err != nil {
 		log.Printf("validation error while creating user %+v: %v", req.User, err)
@@ -814,20 +831,6 @@ type CreateUserResponse struct {
 	Password string `json:"password"`
 }
 
-// Make sure that only user-settable fields are set in the User in a
-// CreateUserRequest.
-func fillUserTemplate(user *User) {
-	// Some fields should be unset because there are specific
-	// methods to modify those attributes.
-	user.Has2FA = false
-	user.HasEncryptionKeys = false
-	user.PasswordRecoveryHint = ""
-	user.AppSpecificPasswords = nil
-	if user.Lang == "" {
-		user.Lang = "en"
-	}
-}
-
 // CreateUser creates a new user along with the associated resources.
 func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserRequest) (*CreateUserResponse, error) {
 	var resp CreateUserResponse
diff --git a/actions_test.go b/actions_test.go
index f9cf9245ae4cb85f43897edef67a0e132a11b678..393416fce41fd8b17aa01989d16a6848ddc83af6 100644
--- a/actions_test.go
+++ b/actions_test.go
@@ -26,6 +26,10 @@ func (b *fakeBackend) Commit(_ context.Context) error {
 	return nil
 }
 
+func (b *fakeBackend) NextUID(_ context.Context) (int, error) {
+	return 42, nil
+}
+
 func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) {
 	return b.users[username], nil
 }
@@ -176,6 +180,7 @@ func createFakeBackend() *fakeBackend {
 	}
 	fb.addUser(&User{
 		Name: "testuser",
+		UID:  4242,
 		Resources: []*Resource{
 			{
 				ID:     NewResourceID(ResourceTypeEmail, "testuser", "testuser@example.com"),
diff --git a/backend/model.go b/backend/model.go
index 6d3cb4577e65ddc3517a1c1c95c7f0d3e661df15..b0108d0a810e45ddaf2f711699098f6c7888f03f 100644
--- a/backend/model.go
+++ b/backend/model.go
@@ -2,6 +2,9 @@ package backend
 
 import (
 	"context"
+	"fmt"
+	"math/rand"
+	"strconv"
 	"strings"
 
 	ldaputil "git.autistici.org/ai3/go-common/ldap"
@@ -22,6 +25,7 @@ const (
 	storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
 	passwordLDAPAttr          = "userPassword"
 	u2fRegistrationsLDAPAttr  = "u2fRegistration"
+	uidNumberLDAPAttr         = "uidNumber"
 )
 
 // backend is the interface to an LDAP-backed user database.
@@ -36,8 +40,16 @@ type backend struct {
 	userQuery           *queryTemplate
 	userResourceQueries []*queryTemplate
 	resources           *resourceRegistry
+
+	// Range for new user IDs.
+	minUID, maxUID int
 }
 
+const (
+	defaultMinUID = 10000
+	defaultMaxUID = 1000000
+)
+
 // backendTX holds the business logic (that runs within a single
 // transaction).
 type backendTX struct {
@@ -95,6 +107,8 @@ func newLDAPBackendWithConn(conn ldapConn, base string) (*backend, error) {
 			},
 		},
 		resources: rsrc,
+		minUID:    defaultMinUID,
+		maxUID:    defaultMaxUID,
 	}, nil
 }
 
@@ -107,9 +121,11 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
 	// The case of password recovery attributes is more complex:
 	// the current schema has those on email=, but we'd like to
 	// move them to uid=, so we currently have to support both.
+	uidNumber, _ := strconv.Atoi(entry.GetAttributeValue(uidNumberLDAPAttr))
 	user := &accountserver.User{
 		Name:                 entry.GetAttributeValue("uid"),
 		Lang:                 entry.GetAttributeValue(preferredLanguageLDAPAttr),
+		UID:                  uidNumber,
 		PasswordRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr),
 		U2FRegistrations:     decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)),
 	}
@@ -131,6 +147,7 @@ func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) {
 		{Type: "objectClass", Vals: []string{"top", "person", "posixAccount", "shadowAccount", "organizationalPerson", "inetOrgPerson", "totpAccount"}},
 		{Type: "uid", Vals: s2l(user.Name)},
 		{Type: "cn", Vals: s2l(user.Name)},
+		{Type: uidNumberLDAPAttr, Vals: s2l(strconv.Itoa(user.UID))},
 		{Type: "givenName", Vals: []string{"Private"}},
 		{Type: "sn", Vals: []string{"Private"}},
 		{Type: "gecos", Vals: s2l(user.Name)},
@@ -481,6 +498,47 @@ func (tx *backendTX) UpdateResource(ctx context.Context, r *accountserver.Resour
 	return nil
 }
 
+// NextUID returns an available user ID (uidNumber). It does so
+// without keeping state by just guessing random UIDs in the
+// minUID-maxUID range, and checking if they are available (so it's
+// best to keep the range largely underutilized).
+func (tx *backendTX) NextUID(ctx context.Context) (uid int, err error) {
+	// Won't actually run forever, at some point the context will expire.
+	var ok bool
+	for !ok && err == nil {
+		uid = tx.backend.minUID + rand.Intn(tx.backend.maxUID-tx.backend.minUID)
+		ok, err = tx.isUIDAvailable(ctx, uid)
+	}
+	return
+}
+
+func (tx *backendTX) isUIDAvailable(ctx context.Context, uid int) (bool, error) {
+	// Try to make this query lightweight: ask for no attributes,
+	// use a size limit of 1 and treat "Size Limit Exceeded"
+	// errors as a successful result.
+	result, err := tx.search(ctx, ldap.NewSearchRequest(
+		tx.backend.baseDN,
+		ldap.ScopeWholeSubtree,
+		ldap.NeverDerefAliases,
+		1, // just one result is enough
+		0,
+		false,
+		fmt.Sprintf("(uidNumber=%d)", uid),
+		[]string{"dn"},
+		nil,
+	))
+	if err != nil {
+		if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) {
+			return false, nil
+		}
+		return false, err
+	}
+	if len(result.Entries) > 0 {
+		return false, nil
+	}
+	return true, nil
+}
+
 var siteRoot = "/home/users/investici.org/"
 
 // The hosting directory for a website is the path component immediately after
diff --git a/backend/model_test.go b/backend/model_test.go
index 151b68725536a5e26b974fa4c9488de78ebdcaf8..49b3cd5e7d024e946bcf24ef90b9d49033df5720 100644
--- a/backend/model_test.go
+++ b/backend/model_test.go
@@ -320,3 +320,40 @@ func TestModel_SetUserEncryptionKeys_Replace(t *testing.T) {
 		t.Fatal("Commit", err)
 	}
 }
+
+func TestModel_NextUID(t *testing.T) {
+	stop, b, user := startServerAndGetUser(t)
+	defer stop()
+	tx, _ := b.NewTransaction()
+
+	// User UID should not be available.
+	ok, err := tx.(*backendTX).isUIDAvailable(context.Background(), user.UID)
+	if err != nil {
+		t.Fatal("isUIDAvailable", err)
+	}
+	if ok {
+		t.Fatalf("oops, the uid of the existing user (%d) appears to be available", user.UID)
+	}
+
+	// Check a UID that should be available instead.
+	freeUID := 4096
+	ok, err = tx.(*backendTX).isUIDAvailable(context.Background(), freeUID)
+	if err != nil {
+		t.Fatal("isUIDAvailable", err)
+	}
+	if !ok {
+		t.Fatalf("oops, a supposedly free uid (%d) appears to be unavailable", freeUID)
+	}
+
+	// Generate a new UID.
+	uid, err := tx.NextUID(context.Background())
+	if err != nil {
+		t.Fatal("NextUID", err)
+	}
+	if uid == 0 {
+		t.Fatal("uid is 0")
+	}
+	if uid == user.UID { // not that it's likely
+		t.Fatalf("uid is the same as the existing user ID (%d)", user.UID)
+	}
+}
diff --git a/service.go b/service.go
index 4f2c06d04db91a9ee07adcaa1318b4ed5f440aae..c5dd78628984c4ff0a35ce940d94d63175026439 100644
--- a/service.go
+++ b/service.go
@@ -62,6 +62,9 @@ type TX interface {
 	// necessary information.
 	GetUserEncryptedPassword(context.Context, *User) string
 	GetUserRecoveryEncryptedPassword(context.Context, *User) string
+
+	// Return the next (or any, really) available user ID.
+	NextUID(context.Context) (int, error)
 }
 
 // FindResourceRequest contains parameters for searching a resource by name.
@@ -320,7 +323,7 @@ type hasNewContext interface {
 }
 
 type hasApplyTemplate interface {
-	ApplyTemplate(context.Context, *AccountService, *User)
+	ApplyTemplate(context.Context, TX, *AccountService, *User) error
 }
 
 type hasValidate interface {
@@ -358,7 +361,9 @@ func (s *AccountService) withRequest(ctx context.Context, tx TX, req interface{}
 	// Apply a template to the request, to fill in default values
 	// etc., if the request has an ApplyTemplate() method.
 	if rt, ok := req.(hasApplyTemplate); ok {
-		rt.ApplyTemplate(ctx, s, user)
+		if err := rt.ApplyTemplate(ctx, tx, s, user); err != nil {
+			return err
+		}
 	}
 
 	// If the request has a Validate() method, validate the
diff --git a/types.go b/types.go
index 68a3b526bc829893ab9f5b55c421f281a594f053..836a048824b45c5d621f82aa89bdc77dfeff41e8 100644
--- a/types.go
+++ b/types.go
@@ -27,6 +27,9 @@ type User struct {
 	// Preferred language.
 	Lang string `json:"lang"`
 
+	// UNIX user id.
+	UID int `json:"uid"`
+
 	Has2FA               bool   `json:"has_2fa"`
 	HasEncryptionKeys    bool   `json:"has_encryption_keys"`
 	PasswordRecoveryHint string `json:"password_recovery_hint"`
@@ -303,12 +306,14 @@ type MailingList struct {
 
 // WebDAV represents a hosting account.
 type WebDAV struct {
+	UID     int    `json:"uid"`
 	Homedir string `json:"homedir"`
 }
 
 // Website resource attributes.
 type Website struct {
 	URL          string            `json:"url,omitempty"`
+	UID          int               `json:"uid"`
 	ParentDomain string            `json:"parent_domain,omitempty"`
 	AcceptMail   bool              `json:"accept_mail"`
 	Options      []string          `json:"options,omitempty"`
diff --git a/validators.go b/validators.go
index 8e3c4bbde0b3da648a2fa202f2a27e1bbda1d629..489db70c318e4a7c1e33553000e65f38b6761126 100644
--- a/validators.go
+++ b/validators.go
@@ -38,6 +38,8 @@ type validationContext struct {
 	forbiddenPasswords stringSet
 	minPasswordLength  int
 	maxPasswordLength  int
+	minUID             int
+	maxUID             int
 	webroot            string
 	domains            domainBackend
 	shards             shardBackend
@@ -479,6 +481,37 @@ func hasMatchingDAVAccount(user *User, r *Resource) bool {
 	return findMatchingDAVAccount(user, r) != nil
 }
 
+func (v *validationContext) validateUID(uid int, user *User) error {
+	if uid == 0 {
+		return errors.New("uid is not set")
+	}
+	if uid < v.minUID || (v.maxUID > 0 && uid > v.maxUID) {
+		return errors.New("uid outside of allowed range")
+	}
+	if user != nil && uid != user.UID {
+		return errors.New("uid of resource differs from uid of user")
+	}
+	return nil
+}
+
+func (v *validationContext) validateWebsiteCommon(r *Resource, user *User) error {
+	// Document root checks: must not be empty, and the
+	// user must have a DAV account with a home directory
+	// that is a parent of this document root.
+	if r.Website.DocumentRoot == "" {
+		return errors.New("empty document_root")
+	}
+	if !isSubdir(v.webroot, r.Website.DocumentRoot) {
+		return errors.New("document root outside of web root")
+	}
+	if !hasMatchingDAVAccount(user, r) {
+		return errors.New("website has no matching DAV account")
+	}
+
+	// UID checks.
+	return v.validateUID(r.Website.UID, user)
+}
+
 func (v *validationContext) validDomainResource() ResourceValidatorFunc {
 	domainValidator := allOf(
 		minLength(6),
@@ -506,17 +539,7 @@ func (v *validationContext) validDomainResource() ResourceValidatorFunc {
 			return errors.New("non-empty parent_domain on domain resource")
 		}
 
-		// Document root checks.
-		if r.Website.DocumentRoot == "" {
-			return errors.New("empty document_root")
-		}
-		if !isSubdir(v.webroot, r.Website.DocumentRoot) {
-			return errors.New("document root outside of web root")
-		}
-		if !hasMatchingDAVAccount(user, r) {
-			return errors.New("website has no matching DAV account")
-		}
-		return nil
+		return v.validateWebsiteCommon(r, user)
 	}
 }
 
@@ -548,19 +571,7 @@ func (v *validationContext) validWebsiteResource() ResourceValidatorFunc {
 			return err
 		}
 
-		// Document root checks: must not be empty, and the
-		// user must have a DAV account with a home directory
-		// that is a parent of this document root.
-		if r.Website.DocumentRoot == "" {
-			return errors.New("empty document_root")
-		}
-		if !isSubdir(v.webroot, r.Website.DocumentRoot) {
-			return errors.New("document root outside of web root")
-		}
-		if !hasMatchingDAVAccount(user, r) {
-			return errors.New("website has no matching DAV account")
-		}
-		return nil
+		return v.validateWebsiteCommon(r, user)
 	}
 }
 
@@ -581,7 +592,8 @@ func (v *validationContext) validDAVResource() ResourceValidatorFunc {
 		if !isSubdir(v.webroot, r.DAV.Homedir) {
 			return errors.New("homedir outside of web root")
 		}
-		return nil
+
+		return v.validateUID(r.DAV.UID, user)
 	}
 }
 
@@ -726,6 +738,8 @@ func (c *templateContext) websiteResourceTemplate(ctx context.Context, r *Resour
 		r.Website.Options = []string{"nomail"}
 	}
 
+	r.Website.UID = user.UID
+
 	dav := findMatchingDAVAccount(user, r)
 	c.setResourceShard(ctx, r, dav)
 	c.setResourceStatus(r)
@@ -741,6 +755,7 @@ func (c *templateContext) davResourceTemplate(ctx context.Context, r *Resource,
 		r.DAV.Homedir = filepath.Join(c.webroot, r.ID.Name())
 	}
 	r.DAV.Homedir = filepath.Clean(r.DAV.Homedir)
+	r.DAV.UID = user.UID
 
 	c.setResourceShard(ctx, r, nil)
 	c.setResourceStatus(r)